1405/04/27
-responive bugs
This commit is contained in:
Vendored
+1
@@ -70,6 +70,7 @@ declare module 'vue' {
|
||||
Switchable: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/switchable.vue')['default']
|
||||
Text: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/text.vue')['default']
|
||||
Time: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/time.vue')['default']
|
||||
TradeFeeConfirmSummary: typeof import('./src/components/TradeFeeConfirmSummary.vue')['default']
|
||||
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
|
||||
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
||||
VanDialog: typeof import('vant/es')['Dialog']
|
||||
|
||||
@@ -217,4 +217,12 @@ onMounted(async () => {
|
||||
transform: scale(0.94);
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
/* در دسکتاپ، دکمه پشتیبانی کنار سایدبار و پایین صفحه قرار میگیرد (مانند khaneghi_user) */
|
||||
html.app-layout--desktop {
|
||||
#support-button {
|
||||
bottom: 12px;
|
||||
right: calc(var(--app-desktop-sidebar-width, 260px) + 20px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@import (less) '../../views/base/user/desktop/styles/desktop-user.less';
|
||||
@import (less) '../../views/base/priceTrigger/desktop/styles/desktop-price-trigger.less';
|
||||
@import (less) '../../views/base/home/desktop/styles/desktop-home.less';
|
||||
@import (less) '../../views/base/gold/desktop/styles/desktop-gold.less';
|
||||
@import (less) '../../views/login/desktop/styles/desktop-login.less';
|
||||
@import (less) '../../layouts/desktop/styles/desktop-inner-pages.less';
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<template>
|
||||
<van-dialog
|
||||
class="updateButton"
|
||||
confirm-button-text="متوجه شدم"
|
||||
v-model:show="show"
|
||||
class="gold-loading-dialog updateButton"
|
||||
teleport="body"
|
||||
:z-index="zIndex"
|
||||
:show-confirm-button="false"
|
||||
@confirm="update()"
|
||||
:close-on-click-overlay="false"
|
||||
:close-on-popstate="false"
|
||||
>
|
||||
<div class="flex flex-col items-center p-2">
|
||||
<DotLottieVue class="w-15rem h-15rem" autoplay loop src="/animIsTracking.json" />
|
||||
@@ -16,21 +19,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { update } from 'lodash'
|
||||
import { ref } from 'vue'
|
||||
import anim from '@/assets/img/anim/anim1.json'
|
||||
import { computed } from 'vue'
|
||||
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
||||
interface Prop {
|
||||
title: string
|
||||
info?: string
|
||||
infoHead?: string
|
||||
isLine?: boolean
|
||||
value?: string | number
|
||||
}
|
||||
const props = withDefaults(defineProps<Prop>(), {
|
||||
isLine: false
|
||||
import { APP_Z_OVERLAY } from '@/utils/zIndex'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show?: boolean
|
||||
zIndex?: number
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
zIndex: APP_Z_OVERLAY,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
}>()
|
||||
|
||||
const show = computed({
|
||||
get: () => props.show,
|
||||
set: (value: boolean) => emit('update:show', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
|
||||
@@ -66,7 +66,7 @@ async function onSupportClick() {
|
||||
padding: 0.65rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--App-primary, var(--ant-color-primary, #7f1d1d));
|
||||
background: var(--App-primary, @App-primary-color);
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="w-full rounded-lg bg-gray-50 p-3 trade-fee-summary">
|
||||
<div class="flex justify-between items-center pb-3 border-b border-gray-200">
|
||||
<div class="text-sm text-gray-600">مظنه هر {{ weightUnit }}</div>
|
||||
<div class="flex items-center font-bold">
|
||||
<span>{{ formattedUnitPrice }}</span>
|
||||
<span class="text-xs mr-1 font-normal">ریال</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(section, index) in breakdown.sections"
|
||||
:key="section.heading"
|
||||
class="mt-3"
|
||||
:class="{ 'pt-3 border-t border-gray-200': index > 0 }"
|
||||
>
|
||||
<div class="text-sm font-bold text-gray-800 mb-2">{{ section.heading }}</div>
|
||||
|
||||
<div
|
||||
v-for="(row, rowIndex) in section.rows"
|
||||
:key="`${section.heading}-${rowIndex}`"
|
||||
class="flex justify-between items-center py-1"
|
||||
:class="{
|
||||
'border-t border-dashed border-gray-300 mt-1 pt-2': row.dividerBefore,
|
||||
'font-bold text-base': row.bold,
|
||||
}"
|
||||
>
|
||||
<div class="text-sm" :class="row.bold ? 'text-gray-900' : 'text-gray-700'">
|
||||
<span v-if="row.plus" class="text-green-700 ml-1">+</span>
|
||||
<span v-else-if="row.minus && !row.bold" class="text-red-600 ml-1">−</span>
|
||||
{{ row.label }}
|
||||
</div>
|
||||
<div
|
||||
class="text-left text-sm"
|
||||
:class="{
|
||||
'text-red-600': row.minus && !row.bold,
|
||||
'text-green-700': row.plus,
|
||||
}"
|
||||
>
|
||||
<template v-if="row.gram != null">
|
||||
<span>{{ formatTradeGramDisplay(row.gram) }}</span>
|
||||
<span class="text-xs mr-1">{{ weightUnit }}</span>
|
||||
</template>
|
||||
<template v-else-if="row.rial != null">
|
||||
<span v-if="row.minus && row.bold">−</span>
|
||||
<span>{{ formatRial(row.rial) }}</span>
|
||||
<span class="text-xs mr-1">ریال</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="section.footnote" class="text-xs text-gray-600 mt-2 leading-relaxed bg-white rounded-lg px-2 py-1.5">
|
||||
{{ section.footnote }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||
import {
|
||||
buildTradeConfirmBreakdown,
|
||||
formatTradeGramDisplay,
|
||||
type TradeInputLock,
|
||||
} from '@/utils/serviceFee'
|
||||
|
||||
const props = defineProps<{
|
||||
isBuy: boolean
|
||||
inputLock: TradeInputLock
|
||||
displayTradeGoldAmount: number
|
||||
weightUnit: string
|
||||
unitPriceRial: number
|
||||
feeInGrams: number
|
||||
equivalentPrice: string
|
||||
payablePrice: number
|
||||
orderGoldAmount: number
|
||||
feeInRial: number
|
||||
sellGoldDebit?: number
|
||||
}>()
|
||||
|
||||
const formattedUnitPrice = computed(() =>
|
||||
usePriceWithoutType(Math.round(props.unitPriceRial))
|
||||
)
|
||||
|
||||
function formatRial(value: number) {
|
||||
return usePriceWithoutType(Math.round(value))
|
||||
}
|
||||
|
||||
const breakdown = computed(() =>
|
||||
buildTradeConfirmBreakdown({
|
||||
isBuy: props.isBuy,
|
||||
inputLock: props.inputLock,
|
||||
orderGoldAmount: props.orderGoldAmount,
|
||||
feeInGrams: props.feeInGrams,
|
||||
feeInRial: props.feeInRial,
|
||||
payablePrice: props.payablePrice,
|
||||
unitPriceRial: props.unitPriceRial,
|
||||
sellGoldDebit: props.sellGoldDebit,
|
||||
})
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchTrackingSettings, isTrackingSystemsEnabled } from '@/utils/trackingSystemsSettings'
|
||||
|
||||
export function useTrackingSystemsFeature() {
|
||||
const trackingSystemsEnabled = ref(false)
|
||||
const trackingSystemsReady = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await fetchTrackingSettings()
|
||||
trackingSystemsEnabled.value = isTrackingSystemsEnabled(settings)
|
||||
} finally {
|
||||
trackingSystemsReady.value = true
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
trackingSystemsEnabled,
|
||||
trackingSystemsReady,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ export const GUEST_PRIMARY_NAV: AppNavItem[] = [
|
||||
{ id: 'login', routeName: 'login', label: 'ورود', icon: 'vuesax-linear:login' },
|
||||
]
|
||||
|
||||
export const RECOVERY_SITES_NAV_ITEM: AppNavItem = {
|
||||
id: 'recoverySites',
|
||||
routeName: 'recoverySites',
|
||||
label: 'سایتهای ریگیری',
|
||||
icon: 'vuesax-linear:global',
|
||||
}
|
||||
|
||||
/** منوی FAB موبایل + بخش فرعی سایدبار دسکتاپ — کاربر لاگینشده */
|
||||
export const AUTH_SECONDARY_NAV: AppNavItem[] = [
|
||||
{ id: 'notifyTrigger', routeName: 'notifyTrigger', label: 'هشدار قیمت', icon: 'vuesax-linear:bill' },
|
||||
@@ -34,6 +41,13 @@ export function getPrimaryNavItems(isAuthenticated: boolean): AppNavItem[] {
|
||||
return isAuthenticated ? AUTH_PRIMARY_NAV : GUEST_PRIMARY_NAV
|
||||
}
|
||||
|
||||
export function getSecondaryNavItems(isAuthenticated: boolean): AppNavItem[] {
|
||||
return isAuthenticated ? AUTH_SECONDARY_NAV : []
|
||||
export function getSecondaryNavItems(
|
||||
isAuthenticated: boolean,
|
||||
options?: { includeRecoverySites?: boolean },
|
||||
): AppNavItem[] {
|
||||
if (!isAuthenticated) return []
|
||||
if (options?.includeRecoverySites) {
|
||||
return [...AUTH_SECONDARY_NAV, RECOVERY_SITES_NAV_ITEM]
|
||||
}
|
||||
return AUTH_SECONDARY_NAV
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { computed } from 'vue'
|
||||
import logoFull from '@/assets/img/logo_full_.png'
|
||||
import { isToken } from '@/utils'
|
||||
import { useCartStore } from '@/store/cartStore'
|
||||
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
|
||||
import { getPrimaryNavItems, getSecondaryNavItems } from '../config/navigation'
|
||||
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||
import DesktopNavItem from './DesktopNavItem.vue'
|
||||
@@ -49,8 +50,15 @@ import DesktopNavItem from './DesktopNavItem.vue'
|
||||
const cartStore = useCartStore()
|
||||
const authenticated = computed(() => isToken())
|
||||
const navLogo = logoFull
|
||||
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
|
||||
const primaryItems = computed(() => getPrimaryNavItems(authenticated.value))
|
||||
const secondaryItems = computed(() => getSecondaryNavItems(authenticated.value))
|
||||
/** «لیست دریافتیها» فقط در موبایل نمایش داده میشود */
|
||||
const DESKTOP_HIDDEN_NAV_IDS = ['list']
|
||||
const secondaryItems = computed(() =>
|
||||
getSecondaryNavItems(authenticated.value, {
|
||||
includeRecoverySites: trackingSystemsEnabled.value,
|
||||
}).filter((item) => !DESKTOP_HIDDEN_NAV_IDS.includes(item.id)),
|
||||
)
|
||||
const cartCount = computed(() => cartStore.itemCount || 0)
|
||||
|
||||
const { isNavActive, navigateTo } = useAppNavigation()
|
||||
|
||||
@@ -87,6 +87,17 @@
|
||||
<div>درباره ما</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton
|
||||
v-if="authenticated && trackingSystemsEnabled"
|
||||
icon-size="16"
|
||||
class="!font-bold w-15rem"
|
||||
@click="go('recoverySites')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<BasicIcon icon="vuesax-linear:global" size="16" class="absolute right-2" />
|
||||
<div>سایتهای ریگیری</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton
|
||||
v-if="authenticated"
|
||||
icon-size="16"
|
||||
@@ -108,6 +119,7 @@
|
||||
import { computed } from 'vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
|
||||
import { isToken } from '@/utils'
|
||||
|
||||
defineProps<{
|
||||
@@ -120,6 +132,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const authenticated = computed(() => isToken())
|
||||
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
|
||||
|
||||
function go(routeName: string) {
|
||||
emit('navigate', routeName)
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Product {
|
||||
unit: string
|
||||
active: boolean
|
||||
show: boolean
|
||||
show_in_assets?: boolean
|
||||
open_sell: boolean
|
||||
open_buy: boolean
|
||||
group_type:string
|
||||
@@ -19,6 +20,11 @@ export interface Product {
|
||||
cash_fee_sell: number | undefined
|
||||
cash_fee_buy_is_percent?: boolean | null
|
||||
cash_fee_sell_is_percent?: boolean | null
|
||||
order_limit_enabled?: boolean
|
||||
min_order_cash?: number | null
|
||||
max_order_cash?: number | null
|
||||
has_physical_delivery?: boolean
|
||||
min_physical_delivery_quantity?: number | null
|
||||
reading_method: any
|
||||
created_at: any
|
||||
updated_at: any
|
||||
|
||||
@@ -108,6 +108,11 @@ const router = createRouter({
|
||||
name: 'rules',
|
||||
component: () => import('@/views/base/rules/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'recoverySites',
|
||||
name: 'recoverySites',
|
||||
component: () => import('@/views/base/recoverySites/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
name: 'permissions',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getSettings } from '@/api/configuration'
|
||||
import { resolveMeltedGoldLabel, resolveMetalTypeLabel } from '@/utils/metalLabels'
|
||||
import { resolveTradeWeightUnit } from '@/utils/weightUnit'
|
||||
|
||||
export interface AppSetting {
|
||||
id?: number
|
||||
@@ -67,11 +69,20 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
const metalTypeSettingValue = computed(() => getSettingByKey('metal_type'))
|
||||
const metalTypeLabel = computed(() => resolveMetalTypeLabel(metalTypeSettingValue.value))
|
||||
const meltedGoldLabel = computed(() => resolveMeltedGoldLabel(metalTypeSettingValue.value))
|
||||
const tradeWeightUnit = computed(() => resolveTradeWeightUnit(getSettingByKey('unit')))
|
||||
|
||||
return {
|
||||
settings,
|
||||
loaded,
|
||||
fetchSettings,
|
||||
mergeSettings,
|
||||
getSettingByKey,
|
||||
metalTypeSettingValue,
|
||||
metalTypeLabel,
|
||||
meltedGoldLabel,
|
||||
tradeWeightUnit,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function resolveMetalTypeLabel(settingValue?: string | number | null): string {
|
||||
const value = settingValue?.toString().trim()
|
||||
return value ? value : 'طلا'
|
||||
}
|
||||
|
||||
export function resolveMeltedGoldLabel(settingValue?: string | number | null): string {
|
||||
const value = settingValue?.toString().trim()
|
||||
return value ? value : 'آبشده'
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function getProductDisplayImage(product?: {
|
||||
return resolveProductImageSrc(product)
|
||||
}
|
||||
|
||||
import { formatGoldAmount } from '@/utils/usePrice'
|
||||
import { formatGoldAmount, usePriceWithoutType } from '@/utils/usePrice'
|
||||
|
||||
export function formatMetalTotal(
|
||||
metal: WalletMetalTotal,
|
||||
@@ -71,3 +71,44 @@ export function resolveAssetsRialValue(
|
||||
?.metal_totals?.[0]?.total_weight
|
||||
return cash + Number(metalWeight ?? 0) * liveGoldPrice
|
||||
}
|
||||
|
||||
export function getProductInventoryQuantity(
|
||||
cards: WalletInventoryCard[],
|
||||
productId?: number | null,
|
||||
mainWalletGold?: number | null,
|
||||
): number {
|
||||
const card = cards.find((item) => Number(item.product_id) === Number(productId))
|
||||
return card ? Number(card.quantity ?? 0) : Number(mainWalletGold ?? 0)
|
||||
}
|
||||
|
||||
export function formatProductWalletBalanceText(
|
||||
product:
|
||||
| { id?: number; title?: string | null; unit?: string | null; group_type?: string | null }
|
||||
| null,
|
||||
cards: WalletInventoryCard[] = [],
|
||||
mainWalletGold?: number | null,
|
||||
): string {
|
||||
const card = cards.find((item) => Number(item.product_id) === Number(product?.id))
|
||||
const quantity = card ? Number(card.quantity ?? 0) : Number(mainWalletGold ?? 0)
|
||||
const unit = card?.unit ?? product?.unit?.trim() ?? 'گرم'
|
||||
const title = product?.title?.trim() || 'محصول'
|
||||
const formatted = unit === 'عدد'
|
||||
? usePriceWithoutType(quantity, false)
|
||||
: formatGoldAmount(quantity)
|
||||
|
||||
return `موجودی کیف ${title}: ${formatted} ${unit}`
|
||||
}
|
||||
|
||||
export function getCashWalletBalance(
|
||||
walletInfo?: Record<string, unknown> | null,
|
||||
wallets?: unknown,
|
||||
): number {
|
||||
const direct = Number(walletInfo?.cash ?? walletInfo?.count_cash_wallets ?? 0)
|
||||
if (direct) return direct
|
||||
|
||||
if (Array.isArray(wallets)) {
|
||||
return wallets.reduce((sum, wallet) => sum + Number(wallet?.cash ?? 0), 0)
|
||||
}
|
||||
|
||||
return Number((wallets as { cash?: number } | null)?.cash ?? 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
|
||||
export function getWeightUnit() {
|
||||
return useSettingsStore().tradeWeightUnit
|
||||
}
|
||||
|
||||
export function formatCashWalletBalanceText(
|
||||
cashFormatted: string | number,
|
||||
_groupType?: string | null,
|
||||
) {
|
||||
return `موجودی کیف پول: ${cashFormatted} ریال`
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Product } from '@/models/products'
|
||||
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
||||
|
||||
type LimitProduct = Pick<Product, 'order_limit_enabled' | 'min_order_cash' | 'max_order_cash'> | null | undefined
|
||||
|
||||
function parseOrderLimitCash(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const raw = persianToEnglishDigits(String(value).trim().replace(/[,\s٬]/g, ''))
|
||||
if (raw === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const num = Number(raw)
|
||||
if (!Number.isFinite(num) || num < 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Math.trunc(num)
|
||||
}
|
||||
|
||||
function isOrderLimitFlagEnabled(value: unknown): boolean {
|
||||
if (value === true || value === 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return normalized === '1' || normalized === 'true'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function productOrderLimitEnabled(product?: LimitProduct): boolean {
|
||||
if (!product) {
|
||||
return false
|
||||
}
|
||||
|
||||
const min = parseOrderLimitCash(product.min_order_cash)
|
||||
const max = parseOrderLimitCash(product.max_order_cash)
|
||||
if (min != null || max != null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isOrderLimitFlagEnabled(product.order_limit_enabled)
|
||||
}
|
||||
|
||||
export function productMinOrderCash(product?: LimitProduct, isBuy = true, defaultBuyMin = 500_000): number {
|
||||
const min = parseOrderLimitCash(product?.min_order_cash)
|
||||
if (productOrderLimitEnabled(product) && min != null) {
|
||||
return min
|
||||
}
|
||||
|
||||
return isBuy ? defaultBuyMin : 0
|
||||
}
|
||||
|
||||
export function productMaxOrderCash(product?: LimitProduct): number | undefined {
|
||||
const max = parseOrderLimitCash(product?.max_order_cash)
|
||||
if (productOrderLimitEnabled(product) && max != null) {
|
||||
return max
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function productCashValidationMessage(
|
||||
product: LimitProduct,
|
||||
goldCash: number | string | undefined,
|
||||
isBuy: boolean,
|
||||
formatPrice: (value: number) => string = usePriceWithoutType
|
||||
): string {
|
||||
if (goldCash === '' || goldCash === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const cash = parseOrderLimitCash(goldCash) ?? 0
|
||||
const min = productMinOrderCash(product, isBuy)
|
||||
const max = productMaxOrderCash(product)
|
||||
|
||||
if (max != null && cash > max) {
|
||||
return `حداکثر مبلغ این محصول ${formatPrice(max)} ریال است`
|
||||
}
|
||||
|
||||
if (min > 0 && cash < min) {
|
||||
if (productOrderLimitEnabled(product) && parseOrderLimitCash(product?.min_order_cash) != null) {
|
||||
return `حداقل مبلغ این محصول ${formatPrice(min)} ریال است`
|
||||
}
|
||||
|
||||
return `حداقل مبلغ طلا جهت ${isBuy ? 'خرید' : 'فروش'} ${formatPrice(min)} ریال می باشد.`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function validateProductOrderLimitForm(form: {
|
||||
order_limit_enabled?: boolean
|
||||
min_order_cash?: number | string | null
|
||||
max_order_cash?: number | string | null
|
||||
}): string | null {
|
||||
if (!form.order_limit_enabled) return null
|
||||
|
||||
const min = form.min_order_cash
|
||||
const max = form.max_order_cash
|
||||
const hasMin = min != null && min !== ''
|
||||
const hasMax = max != null && max !== ''
|
||||
|
||||
if (!hasMin && !hasMax) {
|
||||
return 'با فعال بودن محدودیت مبلغ سفارش، حداقل یکی از حداقل یا حداکثر مبلغ باید وارد شود'
|
||||
}
|
||||
|
||||
if (hasMin && hasMax && Number(min) > Number(max)) {
|
||||
return 'حداقل مبلغ نمیتواند بیشتر از حداکثر باشد'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Product } from '@/models/products'
|
||||
|
||||
type TradeOpenProduct = Pick<Product, 'open_buy' | 'open_sell'> | null | undefined
|
||||
|
||||
function asBool(value: boolean | number | null | undefined) {
|
||||
return value === true || Number(value) === 1
|
||||
}
|
||||
|
||||
export function isProductBuyOpen(product: TradeOpenProduct) {
|
||||
if (!product) return true
|
||||
return asBool(product?.open_buy)
|
||||
}
|
||||
|
||||
export function isProductSellOpen(product: TradeOpenProduct) {
|
||||
if (!product) return true
|
||||
return asBool(product?.open_sell)
|
||||
}
|
||||
|
||||
export function isAnyProductTradeOpen(product: TradeOpenProduct) {
|
||||
return isProductBuyOpen(product) || isProductSellOpen(product)
|
||||
}
|
||||
|
||||
export function isProductTradeOpen(product: TradeOpenProduct, isBuy: boolean) {
|
||||
return isBuy ? isProductBuyOpen(product) : isProductSellOpen(product)
|
||||
}
|
||||
|
||||
/** تب اولیه با توجه به query و وضعیت باز/بسته خرید و فروش */
|
||||
export function resolveProductTradeIsBuy(product: TradeOpenProduct, routeState?: string | null) {
|
||||
const wantSell = routeState === 'sell'
|
||||
const wantBuy = routeState === 'buy'
|
||||
const buyOpen = isProductBuyOpen(product)
|
||||
const sellOpen = isProductSellOpen(product)
|
||||
|
||||
if (wantSell && sellOpen) return false
|
||||
if (wantBuy && buyOpen) return true
|
||||
if (buyOpen) return true
|
||||
if (sellOpen) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function productTradeClosedMessage(isBuy: boolean) {
|
||||
return isBuy
|
||||
? 'خرید این محصول در این لحظه مقدور نمیباشد'
|
||||
: 'فروش این محصول در این لحظه مقدور نمیباشد'
|
||||
}
|
||||
+310
-68
@@ -3,28 +3,63 @@ import { getSettings } from '@/api/configuration'
|
||||
|
||||
export const serviceFeePercentage = ref<number>(0.01)
|
||||
|
||||
/** true = مبالغ رند: فروش → ریال دقیق (کارمزد از طلا)؛ خرید → وزن دقیق (کارمزد از ریال) */
|
||||
/**
|
||||
* @deprecated منطق کارمزد دیگر به این کلید وابسته نیست.
|
||||
* قانون واحد: کاربر هر فیلدی را که پر کند دقیقاً همان معامله میشود و کارمزد از سمت دیگر کسر میشود:
|
||||
* • ریال قفل (کاربر مبلغ زده) → مبلغ دقیق، کارمزد از طلا.
|
||||
* • گرم قفل (کاربر وزن زده) → وزن دقیق، کارمزد از ریال.
|
||||
*/
|
||||
export const feeDeductFromGold = ref(false)
|
||||
|
||||
/** آخرین فیلدی که کاربر مستقیم ویرایش کرده — مبنای بهروزرسانی هنگام تغییر قیمت */
|
||||
export type TradeInputLock = 'gram' | 'rial' | null
|
||||
|
||||
/** ریال قفل است؟ (کاربر مبلغ ریالی را مستقیم وارد کرده) → کارمزد از طلا */
|
||||
export function isRialLocked(inputLock?: TradeInputLock): boolean {
|
||||
return inputLock === 'rial'
|
||||
}
|
||||
|
||||
/** حداکثر اعشار ذخیره/ارسال به سرور (مطابق orders.amount) */
|
||||
export const TRADE_GRAM_STORAGE_DECIMALS = 10
|
||||
|
||||
/** حداکثر اعشار نمایش به کاربر در اپ */
|
||||
export const TRADE_GRAM_DISPLAY_DECIMALS = 8
|
||||
|
||||
/** زیر این مقدار (گرم)، معادل گرم در فیلد ورودی نشان داده نمیشود */
|
||||
export const TRADE_GRAM_MIN_DISPLAY_GRAMS = 0.0001
|
||||
|
||||
function trimTrailingDecimalZeros(fixed: string): string {
|
||||
if (!fixed.includes('.')) {
|
||||
return fixed
|
||||
}
|
||||
|
||||
const trimmed = fixed.replace(/\.?0+$/, '')
|
||||
return trimmed === '' ? '0' : trimmed
|
||||
}
|
||||
|
||||
function formatTradeGramFixedText(grams: number): string {
|
||||
return trimTrailingDecimalZeros(grams.toFixed(TRADE_GRAM_DISPLAY_DECIMALS))
|
||||
}
|
||||
|
||||
export function normalizeOrderGoldAmount(grams?: number | null): number {
|
||||
const value = Number(grams ?? 0)
|
||||
if (!Number.isFinite(value) || value === 0) return 0
|
||||
return parseFloat(value.toFixed(TRADE_GRAM_STORAGE_DECIMALS))
|
||||
}
|
||||
|
||||
export function formatTradeGramDisplay(grams?: number | null): number {
|
||||
/** نمایش گرم در UI — همیشه اعشاری، بدون نماد e */
|
||||
export function formatTradeGramDisplay(grams?: number | null): string {
|
||||
const value = Number(grams ?? 0)
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return parseFloat(value.toFixed(TRADE_GRAM_DISPLAY_DECIMALS))
|
||||
if (!Number.isFinite(value) || value === 0) return '0'
|
||||
return formatTradeGramFixedText(value)
|
||||
}
|
||||
|
||||
/** مقدار فیلد گرم هنگام تبدیل از ریال — مقادیر خیلی ریز خالی میمانند */
|
||||
export function formatTradeGramInputValue(grams?: number | null): string | undefined {
|
||||
const value = Number(grams ?? 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return undefined
|
||||
if (value < TRADE_GRAM_MIN_DISPLAY_GRAMS) return undefined
|
||||
return formatTradeGramFixedText(value)
|
||||
}
|
||||
|
||||
function feeRate() {
|
||||
@@ -67,44 +102,36 @@ export function calcGoldAmountFromTargetRial(rialAmount: number, unitPriceRial:
|
||||
return normalizeOrderGoldAmount(rialAmount / unitPriceRial)
|
||||
}
|
||||
|
||||
/** وقتی کاربر گرم را قفل کرده — ریال نمایشی/قابل پرداخت از همان گرم */
|
||||
/**
|
||||
* کاربر گرم را قفل کرده → وزن دقیق است و کارمزد از ریال کسر میشود.
|
||||
* خرید: مبلغ پرداختی = وزن × مظنه × (۱ + کارمزد).
|
||||
* فروش: مبلغ دریافتی = وزن × مظنه × (۱ − کارمزد).
|
||||
*/
|
||||
export function calcRialFromLockedGram(displayGram: number, unitPriceRial: number, isBuy: boolean) {
|
||||
const grams = Number(displayGram)
|
||||
const unit = Number(unitPriceRial)
|
||||
if (!grams || !unit) return 0
|
||||
const fee = feeRate()
|
||||
|
||||
if (isBuy) {
|
||||
if (feeDeductFromGold.value) {
|
||||
return Math.round(grams * unit * (1 + fee))
|
||||
}
|
||||
return Math.round(grams * unit)
|
||||
}
|
||||
|
||||
if (feeDeductFromGold.value) {
|
||||
return Math.round(grams * unit)
|
||||
}
|
||||
return Math.round(grams * unit * (1 - fee))
|
||||
return isBuy
|
||||
? Math.round(grams * unit * (1 + fee))
|
||||
: Math.round(grams * unit * (1 - fee))
|
||||
}
|
||||
|
||||
/** وقتی کاربر ریال را قفل کرده — گرم نمایشی از همان مبلغ */
|
||||
/**
|
||||
* کاربر ریال را قفل کرده → مبلغ دقیق است و کارمزد از طلا کسر میشود.
|
||||
* خرید: طلای دریافتی = (مبلغ ÷ مظنه) × (۱ − کارمزد) (خالص واریزی به کیف).
|
||||
* فروش: طلای کسرشونده از کیف = (مبلغ ÷ مظنه) × (۱ + کارمزد) (شامل کارمزد).
|
||||
*/
|
||||
export function calcGramFromLockedRial(rialAmount: number, unitPriceRial: number, isBuy: boolean) {
|
||||
const rial = Number(rialAmount)
|
||||
const unit = Number(unitPriceRial)
|
||||
if (!rial || !unit) return 0
|
||||
const fee = feeRate()
|
||||
|
||||
if (isBuy) {
|
||||
if (feeDeductFromGold.value) {
|
||||
return rial / (unit * (1 + fee))
|
||||
}
|
||||
return (rial / unit) * (1 - fee)
|
||||
}
|
||||
|
||||
if (feeDeductFromGold.value) {
|
||||
return rial / unit
|
||||
}
|
||||
return rial / (unit * (1 - fee))
|
||||
return isBuy
|
||||
? (rial / unit) * (1 - fee)
|
||||
: (rial / unit) * (1 + fee)
|
||||
}
|
||||
|
||||
/** مبلغ ریالی قفلشده توسط کاربر — برای ارسال به سرور */
|
||||
@@ -118,7 +145,11 @@ export function calcTradeTargetCash(
|
||||
return Math.round(cash)
|
||||
}
|
||||
|
||||
/** مبلغ نهایی قابل پرداخت (خرید) یا دریافت (فروش) به ریال */
|
||||
/**
|
||||
* مبلغ نهایی قابل پرداخت (خرید) یا دریافت (فروش) به ریال.
|
||||
* • ریال قفل (targetRialCash موجود) → دقیقاً همان مبلغ (کارمزد از طلاست، روی ریال اثر ندارد).
|
||||
* • گرم قفل → کارمزد از ریال: خرید = ارزش + کارمزد، فروش = ارزش − کارمزد.
|
||||
*/
|
||||
export function calcPayablePriceRial(
|
||||
finalPriceRial: number,
|
||||
feeInRial: number,
|
||||
@@ -126,17 +157,21 @@ export function calcPayablePriceRial(
|
||||
targetRialCash?: number | null
|
||||
) {
|
||||
if (targetRialCash != null && Number(targetRialCash) > 0) {
|
||||
return Number(targetRialCash)
|
||||
return Math.round(Number(targetRialCash))
|
||||
}
|
||||
|
||||
if (feeDeductFromGold.value) {
|
||||
if (isBuy) return finalPriceRial + feeInRial
|
||||
return finalPriceRial
|
||||
}
|
||||
return isBuy ? finalPriceRial : Math.max(0, finalPriceRial - feeInRial)
|
||||
return isBuy
|
||||
? Math.round(finalPriceRial + feeInRial)
|
||||
: Math.max(0, Math.round(finalPriceRial - feeInRial))
|
||||
}
|
||||
|
||||
/** وزن ارسالی به سرور */
|
||||
/**
|
||||
* وزن مبنای معامله که به سرور ارسال میشود.
|
||||
* • گرم قفل → همان وزن واردشده (دقیق).
|
||||
* • ریال قفل خرید → طلای خالص دریافتی = (مبلغ ÷ مظنه) × (۱ − کارمزد).
|
||||
* • ریال قفل فروش → وزن معادلِ مبلغ = مبلغ ÷ مظنه (کارمزد جدا از این وزن کسر میشود).
|
||||
* در حالت ریالقفل، سرور مقدار را از target_cash بازمحاسبه میکند؛ این مقدار صرفاً نمایشی/پشتیبان است.
|
||||
*/
|
||||
export function calcOrderGoldAmount(
|
||||
goldValue?: number | null,
|
||||
goldCash?: number | null,
|
||||
@@ -147,60 +182,267 @@ export function calcOrderGoldAmount(
|
||||
const gold = Number(goldValue ?? 0)
|
||||
const cash = Number(goldCash ?? 0)
|
||||
const unit = Number(unitPriceRial ?? 0)
|
||||
const fee = feeRate()
|
||||
|
||||
if (isBuy && inputLock === 'rial' && cash > 0 && unit > 0) {
|
||||
if (feeDeductFromGold.value) {
|
||||
const fee = feeRate()
|
||||
return normalizeOrderGoldAmount(cash / (unit * (1 + fee)))
|
||||
if (inputLock === 'rial' && cash > 0 && unit > 0) {
|
||||
if (isBuy) {
|
||||
return normalizeOrderGoldAmount((cash / unit) * (1 - fee))
|
||||
}
|
||||
return normalizeOrderGoldAmount(cash / unit)
|
||||
}
|
||||
|
||||
if (!isBuy && feeDeductFromGold.value) {
|
||||
if (inputLock === 'rial' && cash > 0 && unit > 0) {
|
||||
return calcGoldAmountFromTargetRial(cash, unit)
|
||||
}
|
||||
if (inputLock !== 'rial') {
|
||||
return normalizeOrderGoldAmount(gold)
|
||||
}
|
||||
const targetCash = calcSellTargetCash(goldCash, goldValue, unitPriceRial, inputLock)
|
||||
if (targetCash != null && unit > 0) {
|
||||
return calcGoldAmountFromTargetRial(targetCash, unit)
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeOrderGoldAmount(gold)
|
||||
}
|
||||
|
||||
/** مبلغ ریالی دقیق دریافتی در فروش (فقط وقتی کاربر با ریال وارد کرده) */
|
||||
/** مبلغ ریالی دقیق (فروش یا خرید) وقتی کاربر ریال را قفل کرده — همان چیزی که به سرور بهعنوان target_cash میرود */
|
||||
export function calcSellTargetCash(
|
||||
goldCash?: number | null,
|
||||
goldValue?: number | null,
|
||||
unitPriceRial?: number,
|
||||
inputLock?: TradeInputLock
|
||||
) {
|
||||
if (!feeDeductFromGold.value) return undefined
|
||||
if (inputLock === 'gram') return undefined
|
||||
|
||||
if (inputLock !== 'rial') return undefined
|
||||
const cash = Number(goldCash ?? 0)
|
||||
if (cash <= 0) return undefined
|
||||
if (inputLock === 'rial') return Math.round(cash)
|
||||
|
||||
const gold = Number(goldValue ?? 0)
|
||||
const unit = Number(unitPriceRial ?? 0)
|
||||
if (gold > 0 && unit > 0) {
|
||||
const derived = Math.round(gold * unit)
|
||||
if (Math.abs(derived - Math.round(cash)) <= 1) return undefined
|
||||
}
|
||||
return Math.round(cash)
|
||||
}
|
||||
|
||||
/** کل طلای کسرشونده از کیف در فروش (شامل کارمزد وقتی fee_deduct_from_gold روشن است) */
|
||||
export function calcSellGoldDebit(goldValue?: number | null, feeInGrams?: number | null) {
|
||||
/**
|
||||
* کل طلای کسرشونده از کیف در فروش.
|
||||
* • ریال قفل → وزن معادل + کارمزد (کارمزد از طلا).
|
||||
* • گرم قفل → فقط همان وزن (کارمزد از ریال کسر میشود، نه از طلا).
|
||||
*/
|
||||
export function calcSellGoldDebit(
|
||||
goldValue?: number | null,
|
||||
feeInGrams?: number | null,
|
||||
inputLock?: TradeInputLock
|
||||
) {
|
||||
const gold = Number(goldValue ?? 0)
|
||||
const fee = Number(feeInGrams ?? 0)
|
||||
if (feeDeductFromGold.value) {
|
||||
if (isRialLocked(inputLock)) {
|
||||
return parseFloat((gold + fee).toFixed(4))
|
||||
}
|
||||
return gold
|
||||
}
|
||||
|
||||
export type FeeDeductionAsset = 'gold' | 'rial'
|
||||
|
||||
/** کارمزد از کدام دارایی کسر میشود — فقط تابع قفل ورودی است (نه خرید/فروش، نه کلید تنظیمات) */
|
||||
export function feeDeductedFrom(inputLock?: TradeInputLock): FeeDeductionAsset {
|
||||
return isRialLocked(inputLock) ? 'gold' : 'rial'
|
||||
}
|
||||
|
||||
/** طلای خالص واریزی به کیف پس از کسر کارمزد (فقط خرید) */
|
||||
export function calcNetGoldCredited(
|
||||
orderGoldAmount: number,
|
||||
feeInGrams: number,
|
||||
isBuy: boolean,
|
||||
inputLock?: TradeInputLock
|
||||
): number {
|
||||
if (!isBuy) return orderGoldAmount
|
||||
// ریال قفل: orderGoldAmount از قبل خالص است. گرم قفل: کارمزد از ریال است، پس همان وزن دریافت میشود.
|
||||
return normalizeOrderGoldAmount(orderGoldAmount)
|
||||
}
|
||||
|
||||
/** مبلغ پایه معامله قبل از اعمال کارمزد ریالی (خرید با افزودن کارمزد به ریال) */
|
||||
export function calcTradeBasePriceRial(orderGoldAmount: number, unitPriceRial: number) {
|
||||
return Math.round(Number(orderGoldAmount) * Number(unitPriceRial))
|
||||
}
|
||||
|
||||
export interface TradeConfirmRow {
|
||||
label: string
|
||||
gram?: number | null
|
||||
rial?: number | null
|
||||
bold?: boolean
|
||||
minus?: boolean
|
||||
plus?: boolean
|
||||
dividerBefore?: boolean
|
||||
}
|
||||
|
||||
export interface TradeConfirmSection {
|
||||
heading: string
|
||||
rows: TradeConfirmRow[]
|
||||
footnote?: string
|
||||
}
|
||||
|
||||
export interface TradeConfirmBreakdown {
|
||||
sections: TradeConfirmSection[]
|
||||
formHint: string
|
||||
}
|
||||
|
||||
function hasTradeFee(feeInGrams: number, feeInRial: number) {
|
||||
return feeInGrams > 0 || feeInRial > 0
|
||||
}
|
||||
|
||||
function grossGoldFromRial(rial: number, unitPriceRial: number) {
|
||||
if (!rial || !unitPriceRial) return 0
|
||||
return normalizeOrderGoldAmount(rial / unitPriceRial)
|
||||
}
|
||||
|
||||
/**
|
||||
* جزئیات مدال تأیید — دو بخش «کسر از کیف» و «واریز به کیف».
|
||||
* فقط بر پایهٔ قفل ورودی تصمیم میگیرد:
|
||||
* • ریال قفل → مبلغ دقیق، کارمزد از طلا.
|
||||
* • گرم قفل → وزن دقیق، کارمزد از ریال.
|
||||
*/
|
||||
export function buildTradeConfirmBreakdown(params: {
|
||||
isBuy: boolean
|
||||
inputLock: TradeInputLock
|
||||
orderGoldAmount: number
|
||||
feeInGrams: number
|
||||
feeInRial: number
|
||||
payablePrice: number
|
||||
unitPriceRial: number
|
||||
sellGoldDebit?: number
|
||||
}): TradeConfirmBreakdown {
|
||||
const {
|
||||
isBuy,
|
||||
inputLock,
|
||||
orderGoldAmount,
|
||||
feeInGrams,
|
||||
feeInRial,
|
||||
payablePrice,
|
||||
unitPriceRial,
|
||||
sellGoldDebit,
|
||||
} = params
|
||||
const hasFee = hasTradeFee(feeInGrams, feeInRial)
|
||||
const rialLocked = isRialLocked(inputLock)
|
||||
const weight = orderGoldAmount
|
||||
|
||||
if (!isBuy) {
|
||||
// ————— فروش —————
|
||||
if (rialLocked) {
|
||||
// مبلغ دقیق دریافتی، کارمزد از طلا
|
||||
const debit = sellGoldDebit ?? parseFloat((weight + feeInGrams).toFixed(4))
|
||||
const goldRows: TradeConfirmRow[] = hasFee
|
||||
? [
|
||||
{ label: 'وزن معادل مبلغ', gram: weight },
|
||||
{ label: 'کارمزد', gram: feeInGrams, minus: true },
|
||||
{ label: 'جمع کسر از کیف طلا', gram: debit, bold: true, dividerBefore: true },
|
||||
]
|
||||
: [{ label: 'وزن فروش', gram: weight, bold: true }]
|
||||
|
||||
return {
|
||||
sections: [
|
||||
{ heading: 'از کیف طلا کسر میشود', rows: goldRows },
|
||||
{
|
||||
heading: 'به کیف پول واریز میشود',
|
||||
rows: [{ label: 'مبلغ دریافتی', rial: payablePrice, bold: true }],
|
||||
footnote: hasFee
|
||||
? 'دقیقاً همان مبلغی که وارد کردید واریز میشود؛ کارمزد فقط از طلای کیف کسر میشود.'
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
formHint: hasFee
|
||||
? `${payablePrice.toLocaleString('fa-IR')} ریال دریافت — ${formatTradeGramDisplay(debit)} گرم از کیف کسر میشود (شامل ${formatTradeGramDisplay(feeInGrams)} گرم کارمزد)`
|
||||
: `${payablePrice.toLocaleString('fa-IR')} ریال دریافت`,
|
||||
}
|
||||
}
|
||||
|
||||
// گرم قفل: وزن دقیق، کارمزد از ریال
|
||||
const grossSellRial = Math.round(weight * unitPriceRial)
|
||||
const cashRows: TradeConfirmRow[] = hasFee
|
||||
? [
|
||||
{ label: 'ارزش فروش', rial: grossSellRial },
|
||||
{ label: 'کارمزد', rial: feeInRial, minus: true },
|
||||
{ label: 'خالص دریافتی', rial: payablePrice, bold: true, dividerBefore: true },
|
||||
]
|
||||
: [{ label: 'مبلغ دریافتی', rial: payablePrice, bold: true }]
|
||||
|
||||
return {
|
||||
sections: [
|
||||
{ heading: 'از کیف طلا کسر میشود', rows: [{ label: 'وزن فروش', gram: weight, bold: true }] },
|
||||
{
|
||||
heading: 'به کیف پول واریز میشود',
|
||||
rows: cashRows,
|
||||
footnote: hasFee ? 'کارمزد از مبلغ ریالی دریافتی کم شده است.' : undefined,
|
||||
},
|
||||
],
|
||||
formHint: hasFee
|
||||
? `${formatTradeGramDisplay(weight)} گرم فروش — ${feeInRial.toLocaleString('fa-IR')} ریال کارمزد از مبلغ دریافتی کسر میشود`
|
||||
: `${formatTradeGramDisplay(weight)} گرم فروش — ${payablePrice.toLocaleString('fa-IR')} ریال به کیف پول`,
|
||||
}
|
||||
}
|
||||
|
||||
// ————— خرید —————
|
||||
if (rialLocked) {
|
||||
// مبلغ دقیق پرداختی، کارمزد از طلا
|
||||
const grossGram = grossGoldFromRial(payablePrice, unitPriceRial)
|
||||
const feeGram = parseFloat(Math.max(0, grossGram - weight).toFixed(4))
|
||||
const goldRows: TradeConfirmRow[] = hasFee
|
||||
? [
|
||||
{ label: 'معادل طلای خام', gram: grossGram },
|
||||
{ label: 'کارمزد', gram: feeGram, minus: true },
|
||||
{ label: 'خالص واریزی به کیف طلا', gram: weight, bold: true, dividerBefore: true },
|
||||
]
|
||||
: [{ label: 'واریزی به کیف طلا', gram: weight, bold: true }]
|
||||
|
||||
return {
|
||||
sections: [
|
||||
{
|
||||
heading: 'از کیف پول کسر میشود',
|
||||
rows: [{ label: 'مبلغ پرداختی', rial: payablePrice, bold: true }],
|
||||
footnote: 'دقیقاً همان مبلغی که وارد کردید؛ کارمزد جداگانه اضافه نمیشود.',
|
||||
},
|
||||
{
|
||||
heading: 'به کیف طلا واریز میشود',
|
||||
rows: goldRows,
|
||||
footnote: hasFee ? 'کارمزد از وزن طلای دریافتی کسر شده است.' : undefined,
|
||||
},
|
||||
],
|
||||
formHint: hasFee
|
||||
? `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت — ${formatTradeGramDisplay(weight)} گرم واریز (کارمزد از طلا کسر شد)`
|
||||
: `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت`,
|
||||
}
|
||||
}
|
||||
|
||||
// گرم قفل خرید: وزن دقیق، کارمزد از ریال
|
||||
const basePriceRial = calcTradeBasePriceRial(weight, unitPriceRial)
|
||||
const cashRows: TradeConfirmRow[] = hasFee
|
||||
? [
|
||||
{ label: `ارزش ${formatTradeGramDisplay(weight)} گرم`, rial: basePriceRial },
|
||||
{ label: 'کارمزد', rial: feeInRial, plus: true },
|
||||
{ label: 'جمع پرداختی', rial: payablePrice, bold: true, dividerBefore: true },
|
||||
]
|
||||
: [{ label: 'مبلغ پرداختی', rial: payablePrice, bold: true }]
|
||||
|
||||
return {
|
||||
sections: [
|
||||
{
|
||||
heading: 'از کیف پول کسر میشود',
|
||||
rows: cashRows,
|
||||
footnote: hasFee ? 'کارمزد به مبلغ پرداختی اضافه شده است.' : undefined,
|
||||
},
|
||||
{
|
||||
heading: 'به کیف طلا واریز میشود',
|
||||
rows: [{ label: 'دقیقاً', gram: weight, bold: true }],
|
||||
},
|
||||
],
|
||||
formHint: hasFee
|
||||
? `${payablePrice.toLocaleString('fa-IR')} ریال (شامل ${feeInRial.toLocaleString('fa-IR')} ریال کارمزد) — ${formatTradeGramDisplay(weight)} گرم واریز`
|
||||
: `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت`,
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated از buildTradeConfirmBreakdown استفاده کنید */
|
||||
export function buildTradeFeeConfirmCopy(params: {
|
||||
isBuy: boolean
|
||||
inputLock: TradeInputLock
|
||||
orderGoldAmount: number
|
||||
feeInGrams: number
|
||||
feeInRial: number
|
||||
payablePrice: number
|
||||
unitPriceRial: number
|
||||
sellGoldDebit?: number
|
||||
}) {
|
||||
const breakdown = buildTradeConfirmBreakdown(params)
|
||||
return {
|
||||
feeDeductFrom: feeDeductedFrom(params.inputLock),
|
||||
feeDeductionLabel: '',
|
||||
feeDeductionHint: breakdown.formHint,
|
||||
payableNote: breakdown.sections.at(-1)?.footnote ?? '',
|
||||
showNetGoldRow: false,
|
||||
showSellDebitRow: false,
|
||||
showBasePriceRow: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getSettings } from '@/api/configuration'
|
||||
import type { AppSetting } from '@/store/settingsStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
|
||||
export const TRACKING_SYSTEMS_KEY = 'tracking_systems'
|
||||
export const TRACKING_SYSTEMS_ENABLED_KEY = 'tracking_systems_enabled'
|
||||
|
||||
export interface TrackingSystemSite {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
let trackingSettingsCache: AppSetting[] | null = null
|
||||
let trackingSettingsPromise: Promise<AppSetting[]> | null = null
|
||||
|
||||
export function normalizeSettingsResponse(data: unknown): AppSetting[] {
|
||||
if (Array.isArray(data)) return data as AppSetting[]
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
const nested = record.data ?? record.settings ?? record.rows
|
||||
if (Array.isArray(nested)) return nested as AppSetting[]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function findSettingValue(settings: AppSetting[], key: string): unknown {
|
||||
const setting = settings.find((item) => String(item?.key ?? '').trim() === key)
|
||||
return setting?.value ?? null
|
||||
}
|
||||
|
||||
export function isSettingTruthy(value: unknown): boolean {
|
||||
if (value === true) return true
|
||||
const normalized = String(value ?? '').trim().toLowerCase()
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes'
|
||||
}
|
||||
|
||||
export function parseTrackingSystems(raw: unknown): TrackingSystemSite[] {
|
||||
let value: unknown = raw
|
||||
|
||||
for (let depth = 0; depth < 2; depth += 1) {
|
||||
if (typeof value !== 'string') break
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
try {
|
||||
value = JSON.parse(trimmed)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
|
||||
const record = item as Record<string, unknown>
|
||||
const id = String(record.id ?? '').trim()
|
||||
const name = String(record.name ?? record.title ?? '').trim()
|
||||
const url = String(record.url ?? record.link ?? '').trim()
|
||||
|
||||
if (!name || !url) return null
|
||||
return { id: id || `${name}-${url}`, name, url }
|
||||
})
|
||||
.filter((item): item is TrackingSystemSite => item != null)
|
||||
}
|
||||
|
||||
export function resolveTrackingSystems(settings: AppSetting[]): TrackingSystemSite[] {
|
||||
return parseTrackingSystems(findSettingValue(settings, TRACKING_SYSTEMS_KEY))
|
||||
}
|
||||
|
||||
export function isTrackingSystemsEnabled(settings: AppSetting[]): boolean {
|
||||
return isSettingTruthy(findSettingValue(settings, TRACKING_SYSTEMS_ENABLED_KEY))
|
||||
}
|
||||
|
||||
export async function fetchTrackingSettings(force = false): Promise<AppSetting[]> {
|
||||
if (!force && trackingSettingsCache) return trackingSettingsCache
|
||||
if (!force && trackingSettingsPromise) return trackingSettingsPromise
|
||||
|
||||
trackingSettingsPromise = (async () => {
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
try {
|
||||
await settingsStore.fetchSettings(force)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
try {
|
||||
const freshSettings = normalizeSettingsResponse(await getSettings())
|
||||
if (freshSettings.length > 0) settingsStore.mergeSettings(freshSettings)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
trackingSettingsCache = [...settingsStore.settings]
|
||||
return trackingSettingsCache
|
||||
})()
|
||||
.catch((error) => {
|
||||
console.log(error)
|
||||
trackingSettingsCache = []
|
||||
return []
|
||||
})
|
||||
.finally(() => {
|
||||
trackingSettingsPromise = null
|
||||
})
|
||||
|
||||
return trackingSettingsPromise
|
||||
}
|
||||
|
||||
export function resetTrackingSettingsCache() {
|
||||
trackingSettingsCache = null
|
||||
trackingSettingsPromise = null
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const TRADE_WEIGHT_UNIT_GRAM = 'گرم'
|
||||
export const TRADE_WEIGHT_UNIT_KILOGRAM = 'کیلوگرم'
|
||||
|
||||
export function isKilogramUnit(unit?: string | null) {
|
||||
const value = (unit ?? '').trim()
|
||||
return value === TRADE_WEIGHT_UNIT_KILOGRAM || value === 'kilogram' || value === 'kg'
|
||||
}
|
||||
|
||||
/** واحد نمایش از ستینگ — خالی یا نامعتبر = گرم */
|
||||
export function resolveTradeWeightUnit(settingValue?: string | number | null) {
|
||||
return isKilogramUnit(settingValue?.toString()) ? TRADE_WEIGHT_UNIT_KILOGRAM : TRADE_WEIGHT_UNIT_GRAM
|
||||
}
|
||||
|
||||
export function weightToGrams(value: number, fromUnit?: string | null) {
|
||||
return isKilogramUnit(fromUnit) ? value * 1000 : value
|
||||
}
|
||||
|
||||
export function gramsToTradeUnit(grams: number, displayUnit?: string | null) {
|
||||
return isKilogramUnit(displayUnit ?? resolveTradeWeightUnit(null)) ? grams / 1000 : grams
|
||||
}
|
||||
|
||||
export function convertWeightToDisplayUnit(
|
||||
value: number,
|
||||
fromUnit?: string | null,
|
||||
displayUnit?: string | null
|
||||
) {
|
||||
return gramsToTradeUnit(weightToGrams(value, fromUnit), displayUnit)
|
||||
}
|
||||
|
||||
export function formatTradeWeight(value: number, displayUnit?: string | null) {
|
||||
const unit = resolveTradeWeightUnit(displayUnit)
|
||||
return `${value} ${unit}`
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Overlay dialogs (loading / blocking) */
|
||||
export const APP_Z_OVERLAY = 5600
|
||||
@@ -33,57 +33,41 @@
|
||||
</div>
|
||||
</div>
|
||||
<a-divider class="!my-3" />
|
||||
<BasicActionSheet v-model:show="infoVisible">
|
||||
<BasicActionSheet v-if="!isDesktop" v-model:show="infoVisible">
|
||||
<div class="font-bold text-lg">{{ props.item.title }}</div>
|
||||
<div class="pb-20 mt-5">
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">مقدار {{ props.title }} مبادله شده:</div>
|
||||
<div
|
||||
:class="item.gold >= 0 ? 'text-green-600' : 'text-red-600'"
|
||||
class="flex text-xs items-center"
|
||||
>
|
||||
<div class="text-base ml-1 number-ltr">{{ formatGoldAmount(item.gold) }}</div>
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">موجودی {{ props.title }}:</div>
|
||||
<div class="flex text-xs text-yellow-700 items-center">
|
||||
<div class="text-base ml-1">{{ formatGoldAmount(item.balance_gold) }}</div>
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">وضعیت:</div>
|
||||
<div class="flex text-xs items-center">
|
||||
<ATag :color="item.active ? 'blue' : 'red'" class="!py-1 !px-6 !ml-0">
|
||||
{{ item.active ? 'موفق' : 'نا موفق' }}
|
||||
</ATag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">تاریخ:</div>
|
||||
<div>{{ toJalaliNotime(item.expire_at) }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">زمان:</div>
|
||||
<div>{{ toTime(item.expire_at) }}</div>
|
||||
</div>
|
||||
<TransactionDetailContent :item="item" :title="props.title" />
|
||||
<BasicButton class="w-full mt-5" @click="infoVisible = false">بازگشت</BasicButton>
|
||||
</div>
|
||||
</BasicActionSheet>
|
||||
<BasicModal
|
||||
v-else
|
||||
v-model:visible="infoVisible"
|
||||
:title="props.item.title"
|
||||
width="440px"
|
||||
custom-footer
|
||||
>
|
||||
<TransactionDetailContent class="mt-2" :item="item" :title="props.title" />
|
||||
<template #footer>
|
||||
<BasicButton class="w-full" @click.stop="infoVisible = false">بازگشت</BasicButton>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
||||
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import type { Transaction } from '@/models/transaction'
|
||||
import { formatGoldAmount } from '@/utils/usePrice'
|
||||
import { toJalaliNotime, toTime } from '@/utils/useDateTime'
|
||||
import { toJalaliNotime } from '@/utils/useDateTime'
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import TransactionDetailContent from './TransactionDetailContent.vue'
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const infoVisible = ref<boolean>(false)
|
||||
|
||||
interface Prop {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">مقدار {{ title }} مبادله شده:</div>
|
||||
<div
|
||||
:class="item.gold >= 0 ? 'text-green-600' : 'text-red-600'"
|
||||
class="flex text-xs items-center"
|
||||
>
|
||||
<div class="text-base ml-1 number-ltr">{{ formatGoldAmount(item.gold) }}</div>
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">موجودی {{ title }}:</div>
|
||||
<div class="flex text-xs text-yellow-700 items-center">
|
||||
<div class="text-base ml-1">{{ formatGoldAmount(item.balance_gold) }}</div>
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">وضعیت:</div>
|
||||
<div class="flex text-xs items-center">
|
||||
<ATag :color="item.active ? 'blue' : 'red'" class="!py-1 !px-6 !ml-0">
|
||||
{{ item.active ? 'موفق' : 'نا موفق' }}
|
||||
</ATag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">تاریخ:</div>
|
||||
<div>{{ toJalaliNotime(item.expire_at) }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">زمان:</div>
|
||||
<div>{{ toTime(item.expire_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Transaction } from '@/models/transaction'
|
||||
import { formatGoldAmount } from '@/utils/usePrice'
|
||||
import { toJalaliNotime, toTime } from '@/utils/useDateTime'
|
||||
|
||||
defineProps<{
|
||||
item: Transaction
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
/* Desktop standalone gold trade page */
|
||||
|
||||
.gold-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.gold-embed {
|
||||
max-height: min(70vh, 35rem);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gold-form-card {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.gold-fee-box {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.dgold {
|
||||
min-height: 100%;
|
||||
background: #f6f7f9;
|
||||
color: #111827;
|
||||
padding: 28px 36px 40px;
|
||||
}
|
||||
|
||||
.dgold-topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||
|
||||
&__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.dgold__panel {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 180px);
|
||||
padding: 0 24px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dgold__panel .mb-25vh {
|
||||
margin-bottom: 2rem !important;
|
||||
}
|
||||
|
||||
.dgold__panel .gold-fee-box {
|
||||
position: static !important;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.dgold {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dgold-topbar {
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.dgold-topbar__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.dgold__panel {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
+310
-196
@@ -1,31 +1,54 @@
|
||||
<template>
|
||||
<div class="h-35rem overflow-y-auto px-5">
|
||||
<div class=" !overflow-y-scroll bg-white py-3 text-primary font-bold flex justify-evenly">
|
||||
<div :class="rootClass">
|
||||
<header v-if="isStandaloneDesktop" class="dgold-topbar">
|
||||
<button type="button" class="dgold-topbar__back" @click="router.back()">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
|
||||
بازگشت
|
||||
</button>
|
||||
<div class="dgold-topbar__meta">
|
||||
<p class="dgold-topbar__eyebrow">معامله</p>
|
||||
<h1 class="dgold-topbar__title">خرید و فروش {{ tradeProductLabel }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<MobilePageHeader
|
||||
v-else-if="isStandaloneMobile"
|
||||
:title="`خرید و فروش ${tradeProductLabel}`"
|
||||
show-back
|
||||
/>
|
||||
<div :class="panelClass">
|
||||
<div
|
||||
:class="[
|
||||
'!overflow-y-scroll bg-white py-3 text-primary font-bold flex justify-evenly',
|
||||
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-primary':'text-gray-400'" class="w-full text-center z-2"
|
||||
@click.stop="setIsBuy(true) ">
|
||||
خرید طلا {{ props?.ProductItem?.title }}
|
||||
<div :class="isBuy?'text-tertiary':'text-gray-400'" class="w-full text-center z-2"
|
||||
@click.stop="setIsBuy(true) "
|
||||
:style="!isProductBuyOpen(props?.ProductItem) ? 'pointer-events: none; opacity: 0.5;' : ''">
|
||||
خرید {{ tradeProductLabel }}
|
||||
</div>
|
||||
<div :class="!isBuy?'text-primary':'text-gray-400'" class="w-full text-center z-2"
|
||||
@click.stop="setIsBuy(false) ">
|
||||
فروش طلا {{ props?.ProductItem?.title }}
|
||||
<div :class="!isBuy?'text-tertiary':'text-gray-400'" class="w-full text-center z-2"
|
||||
@click.stop="setIsBuy(false) "
|
||||
:style="!isProductSellOpen(props?.ProductItem) ? 'pointer-events: none; opacity: 0.5;' : ''">
|
||||
فروش {{ tradeProductLabel }}
|
||||
</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 mb-25vh pb-2">
|
||||
<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">
|
||||
<img :src="img1" alt="" class="h-[88px] absolute">
|
||||
<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 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">
|
||||
<div :class="!isBuy? 'text-red-700' : 'text-green-700'" class="font-bold text-2xl color-transition">
|
||||
{{ usePriceWithoutType(goldPriceValue) }}
|
||||
</div>
|
||||
ریال
|
||||
@@ -37,50 +60,58 @@
|
||||
</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 h-58 relative">
|
||||
<bg_main/>
|
||||
<div class="border-gray-200 border-1 rounded-xl p-5 gold-form-card relative">
|
||||
<bg_main />
|
||||
<div class="z-100 relative">
|
||||
<a-form-item>
|
||||
<BasicInput v-model:value="form.goldCash" addon-before="ریال" addon-bg-color="#a72b77"
|
||||
<BasicInput v-model:value="form.goldCash" addon-before="ریال" :addon-bg-color="colors.primary"
|
||||
addon-text-color="white"
|
||||
place-holder="ارزش کل"
|
||||
size="large"
|
||||
type="price" @onchange="calculateGold" />
|
||||
</a-form-item>
|
||||
<a-form-item :rules="[justNumber()]">
|
||||
<BasicInput v-model:value="form.goldValue" addon-before="گرم" addon-bg-color="#a72b77"
|
||||
<BasicInput v-model:value="form.goldValue" :addon-before="weightUnit" :addon-bg-color="colors.primary"
|
||||
addon-text-color="white"
|
||||
place-holder="مقدار طلا"
|
||||
:place-holder="amountFieldLabel"
|
||||
size="large" type="text" inputmode="decimal" @onchange="calculateCash" />
|
||||
</a-form-item>
|
||||
<div v-if="cashValidationMessage" class="text-red-700 text-xs font-bold mt-2">
|
||||
{{ cashValidationMessage }}
|
||||
</div>
|
||||
<!-- {{walletStore?.walletInfo?.gold}}-->
|
||||
<Transition mode="out-in" name="fade">
|
||||
<div v-if="!isBuy" class="font-bold text-xs">
|
||||
<div> {{ `موجودی کیف طلا: ${formatGoldAmount(walletStore?.walletInfo?.gold ?? 0)} گرم` }}</div>
|
||||
<div>{{ productWalletBalanceText }}</div>
|
||||
<!-- <div v-if="walletStore?.walletInfo?.gold_block !== 0">
|
||||
{{ `موجودی قابل برداشت طلا: ${walletStore?.walletInfo?.gold_block ?? 0} گرم` }}
|
||||
</div>-->
|
||||
</div>
|
||||
<div v-else class="font-bold text-xs">
|
||||
{{ `موجودی کیف پول: ${usePriceWithoutType(walletStore?.walletInfo?.cash ?? 0)} ریال` }}
|
||||
{{ formatCashWalletBalanceText(usePriceWithoutType(cashWalletBalance), props?.ProductItem?.group_type) }}
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition mode="out-in" name="fade">
|
||||
<a-form-item v-if="walletStore?.walletInfo" class="!mt-3 ">
|
||||
<!-- {{ isBuy }}-->
|
||||
<a-slider v-model:value="form.percentWallet"
|
||||
:disabled="isBuy? walletStore?.walletInfo?.cash <= 0: false"
|
||||
:max="100"
|
||||
:min="0"
|
||||
:step="10"
|
||||
:tip-formatter="null"
|
||||
class="!my-1"
|
||||
@change="percentChange" />
|
||||
<div
|
||||
class="app-touch-slider"
|
||||
@touchstart.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<a-slider v-model:value="form.percentWallet"
|
||||
:disabled="isBuy? cashWalletBalance <= 0: false"
|
||||
:max="100"
|
||||
:min="0"
|
||||
:step="10"
|
||||
:tip-formatter="null"
|
||||
class="!my-1"
|
||||
@change="percentChange" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<GoldLoading v-model:show="infoVisible" />
|
||||
<a-form-item>
|
||||
<BasicButton
|
||||
v-if="walletStore"
|
||||
@@ -94,31 +125,28 @@
|
||||
<div v-else>{{ !isBuy ? 'فروش' : 'خرید' }}</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<div v-if="!isBuy && sellGoldDebit > (walletStore?.walletInfo?.gold ?? 0)"
|
||||
<div v-if="!isBuy && sellGoldDebit > productWalletBalance"
|
||||
class="text-red-700 text-xs font-bold mt-2">
|
||||
مقدار گرم طلای وارد شده جهت فروش ({{ form.goldValue }}گرم) بیشتر از مقدار موجودی شما
|
||||
({{ walletStore?.walletInfo?.gold }}گرم) می باشد.
|
||||
<span v-if="feeDeductFromGold"> (با احتساب {{ feeInGrams }} گرم کارمزد، مجموعاً {{ sellGoldDebit }} گرم کسر میشود)</span>
|
||||
مقدار {{ weightUnit }} وارد شده جهت فروش ({{ form.goldValue }}{{ weightUnit }}) بیشتر از مقدار موجودی شما
|
||||
({{ productWalletBalance }}{{ weightUnit }}) می باشد.
|
||||
<span v-if="feeDeductsFromGold"> (با احتساب {{ feeInGrams }}{{ weightUnit }} کارمزد، مجموعاً {{ sellGoldDebit }}{{ weightUnit }} کسر میشود)</span>
|
||||
</div>
|
||||
<!-- <div>{{form?.goldValue? true: false}}</div>-->
|
||||
<div v-if="isBuy? ((form?.goldValue || form.goldValue === 0)? form?.goldCash < 500000: false) : false"
|
||||
<div v-if="isBuy ? (payablePrice > cashWalletBalance) : false"
|
||||
class="text-red-700 text-xs font-bold mt-2">
|
||||
حداقل مقدار طلا جهت خرید 500,000 ریال می باشد.
|
||||
موجودی کیف پول ناکافی می باشد.
|
||||
</div>
|
||||
</a-form-item>
|
||||
<div class=" w-full bottom-18 px-6 rounded-xl py-1 border-1 bg-white">
|
||||
<!-- <BasicInfoItem is-line title="کارمزد" :value="parseFloat(((form.goldValue?? 0)*0.005).toFixed(3))" />-->
|
||||
<!-- <BasicInfoItem is-line title="کارمزد" :value="parseFloat(((form.goldValue?? 0)*0.005).toFixed(3))" />-->
|
||||
<div class="w-full px-4 rounded-xl 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">
|
||||
<div class="">{{ feeInGrams }}</div>
|
||||
<!-- <div class="font-bold text-2xl">{{ 0 }}</div>-->
|
||||
<div>
|
||||
گرم
|
||||
</div>
|
||||
<div>کارمزد ({{ feeDeductsFromGold ? `از طلا` : `از ریال` }}):</div>
|
||||
<div class="flex gap-2 items-center font-bold">
|
||||
<div>{{ feeDisplayText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mt-1">
|
||||
{{ feeFormHint }}
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
<!-- <div ref="el" :class="loading? 'h-screen': ''"
|
||||
@@ -132,56 +160,59 @@
|
||||
</div>-->
|
||||
</div>
|
||||
|
||||
<BasicActionSheet v-model:show="confirmAction">
|
||||
<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">گرم</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">گرم</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">گرم (دقیق — کارمزد از ریال)</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">گرم</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>
|
||||
<BasicButton class="mb-20 mt-5 w-full" type="primary" @click="ok(); confirmAction= false">ثبت نهایی</BasicButton>
|
||||
<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"
|
||||
:disabled="loading"
|
||||
@click="ok"
|
||||
>
|
||||
ثبت نهایی
|
||||
</BasicButton>
|
||||
</BasicActionSheet>
|
||||
<BasicModal
|
||||
v-else
|
||||
v-model:visible="confirmAction"
|
||||
title="تأیید سفارش"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
custom-footer
|
||||
>
|
||||
<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>
|
||||
<template #footer>
|
||||
<div></div>
|
||||
</template>
|
||||
</BasicModal>
|
||||
<GoldLoading v-model:show="infoVisible" />
|
||||
<van-dialog v-model:show="noCompleteVisible" class="completeButtonStyle" confirm-button-text="تکمیل اطلاعات"
|
||||
show-confirm-button @confirm="router.push({name: 'user',query:{showEdit: true}})">
|
||||
<div class="flex flex-col items-center p-2">
|
||||
@@ -194,14 +225,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</van-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import img1 from '@/assets/img/home_ly2.svg'
|
||||
import { usePrice, usePriceWithoutType, formatGoldAmount } from '@/utils/usePrice'
|
||||
import { usePrice, usePriceWithoutType } from '@/utils/usePrice'
|
||||
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
||||
import type { Gold } from '@/models/gold'
|
||||
import { isIphone, loading, loadingMore } from '@/utils'
|
||||
@@ -211,9 +243,14 @@ import { useWalletStore } from '@/store/walletStore'
|
||||
import { createOrder } from '@/api/order'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Price } from '@/models/home'
|
||||
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 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'
|
||||
import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue'
|
||||
import GoldLoading from '@/components/GoldLoading.vue'
|
||||
@@ -226,10 +263,11 @@ import type { Transaction } from '@/models/transaction'
|
||||
import type { TableEngineInput, TableOutput } from '@/models/table'
|
||||
import { useInfiniteScroll } from '@vueuse/core'
|
||||
import { getTransactionTable } from '@/api/transaction'
|
||||
import { getLastPrices, getTradeBuyPrice, getTradeSellPrice, lastPrice, setIntervalGetAllPrice, stopPriceInterval } from '@/utils/price'
|
||||
import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours'
|
||||
import { getProductBuyPrice, getProductSellPrice } from '@/utils/productPrice'
|
||||
import { getLastPrices, getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
|
||||
import type { Product } from '@/models/products'
|
||||
import { useFundUserStore } from '@/store/fundStore'
|
||||
import { colors } from '@/themeConfig'
|
||||
import {
|
||||
calcFeeInGrams,
|
||||
calcGramFromLockedRial,
|
||||
@@ -237,20 +275,46 @@ import {
|
||||
calcPayablePriceRial,
|
||||
calcRialFromLockedGram,
|
||||
calcSellGoldDebit,
|
||||
calcSellTargetCash,
|
||||
calcTradeTargetCash,
|
||||
feeDeductFromGold,
|
||||
buildTradeConfirmBreakdown,
|
||||
formatTradeGramDisplay,
|
||||
formatTradeGramInputValue,
|
||||
loadServiceFeeFromSettings,
|
||||
normalizeOrderGoldAmount,
|
||||
serviceFeePercentage,
|
||||
type TradeInputLock,
|
||||
} from '@/utils/serviceFee'
|
||||
import Bg_main from '@/components/bg_main.vue'
|
||||
import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours'
|
||||
import {
|
||||
isProductBuyOpen,
|
||||
isProductSellOpen,
|
||||
isProductTradeOpen,
|
||||
productTradeClosedMessage,
|
||||
resolveProductTradeIsBuy,
|
||||
} from '@/utils/productTradeOpen'
|
||||
import { formatCashWalletBalanceText, getWeightUnit } from '@/utils/productDealMethod'
|
||||
import { formatProductWalletBalanceText, getCashWalletBalance, getProductInventoryQuantity } from '@/utils/productAssets'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { usesQuantityTrade } from '@/utils/productCategories'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
productCashValidationMessage,
|
||||
productMaxOrderCash,
|
||||
productMinOrderCash
|
||||
} from '@/utils/productOrderLimit'
|
||||
|
||||
const infoVisible = ref(false)
|
||||
const walletStore = useWalletStore()
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { meltedGoldLabel } = storeToRefs(settingsStore)
|
||||
const weightUnit = computed(() => getWeightUnit())
|
||||
const fundStore = useFundUserStore()
|
||||
const { assetsSummary, loadWalletAssets } = useWalletAssets()
|
||||
const cashWalletBalance = computed(() =>
|
||||
getCashWalletBalance(walletStore.walletInfo, userStore.userInfo?.wallets)
|
||||
)
|
||||
const confirmAction = ref<boolean>(false)
|
||||
const noCompleteVisible = ref<boolean>(false)
|
||||
const buyItems = ref<Transaction[]>([])
|
||||
@@ -264,9 +328,47 @@ interface Prop {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Prop>(), {})
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const isStandaloneDesktop = computed(() => isDesktop.value && !props.ProductItem)
|
||||
const isStandaloneMobile = computed(() => !isDesktop.value && !props.ProductItem)
|
||||
const rootClass = computed(() => {
|
||||
if (isStandaloneDesktop.value) return 'dgold'
|
||||
if (props.ProductItem) return 'gold-embed'
|
||||
return 'gold-page'
|
||||
})
|
||||
const panelClass = computed(() => (isStandaloneDesktop.value ? 'dgold__panel' : ''))
|
||||
const contentClass = computed(() => {
|
||||
if (isStandaloneDesktop.value) return 'mt-5 mb-25vh pb-2'
|
||||
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'
|
||||
})
|
||||
const isBuy = ref<boolean>(true)
|
||||
const inputLock = ref<TradeInputLock>(null)
|
||||
|
||||
const tradeProductLabel = computed(() => {
|
||||
const title = props.ProductItem?.title?.trim()
|
||||
return title || meltedGoldLabel.value
|
||||
})
|
||||
const amountFieldLabel = computed(() => `مقدار ${tradeProductLabel.value}`)
|
||||
|
||||
const productWalletBalance = computed(() =>
|
||||
getProductInventoryQuantity(
|
||||
assetsSummary.value?.inventory_cards ?? [],
|
||||
props.ProductItem?.id,
|
||||
walletStore?.walletInfo?.gold
|
||||
)
|
||||
)
|
||||
|
||||
const productWalletBalanceText = computed(() =>
|
||||
formatProductWalletBalanceText(
|
||||
props.ProductItem,
|
||||
assetsSummary.value?.inventory_cards ?? [],
|
||||
walletStore?.walletInfo?.gold
|
||||
)
|
||||
)
|
||||
|
||||
const buyPagination = ref<TableEngineInput>({
|
||||
filters: [{
|
||||
field: 'type',
|
||||
@@ -286,9 +388,13 @@ const sellPagination = ref<TableEngineInput>({
|
||||
}], limit: 6, page: 0
|
||||
})
|
||||
|
||||
const unitPrice = computed(() =>
|
||||
isBuy.value ? getTradeBuyPrice(lastPrice.value) : getTradeSellPrice(lastPrice.value)
|
||||
)
|
||||
const unitPrice = computed(() => {
|
||||
const product = props.ProductItem
|
||||
if (usesQuantityTrade(product)) {
|
||||
return isBuy.value ? getProductBuyPrice(product!) : getProductSellPrice(product!)
|
||||
}
|
||||
return isBuy.value ? getTradeBuyPrice(lastPrice.value) : getTradeSellPrice(lastPrice.value)
|
||||
})
|
||||
const orderGoldAmount = computed(() =>
|
||||
calcOrderGoldAmount(
|
||||
form.value?.goldValue,
|
||||
@@ -310,12 +416,39 @@ const payablePrice = computed(() =>
|
||||
inputLock.value === 'rial' ? form.value?.goldCash : undefined
|
||||
)
|
||||
)
|
||||
const sellGoldDebit = computed(() => calcSellGoldDebit(orderGoldAmount.value, feeInGrams.value))
|
||||
const sellGoldDebit = computed(() =>
|
||||
calcSellGoldDebit(orderGoldAmount.value, feeInGrams.value, inputLock.value)
|
||||
)
|
||||
const feeDeductsFromGold = computed(() => inputLock.value === 'rial')
|
||||
const feeDisplayText = computed(() =>
|
||||
feeDeductsFromGold.value
|
||||
? `${feeInGrams.value} ${weightUnit.value}`
|
||||
: `${usePriceWithoutType(Math.round(feeInRial.value))} ریال`
|
||||
)
|
||||
|
||||
const displayTradeGoldAmount = computed(() =>
|
||||
formatTradeGramDisplay(orderGoldAmount.value)
|
||||
)
|
||||
|
||||
const feeFormHint = computed(() =>
|
||||
buildTradeConfirmBreakdown({
|
||||
isBuy: isBuy.value,
|
||||
inputLock: inputLock.value,
|
||||
orderGoldAmount: orderGoldAmount.value,
|
||||
feeInGrams: feeInGrams.value,
|
||||
feeInRial: feeInRial.value,
|
||||
payablePrice: payablePrice.value,
|
||||
unitPriceRial: unitPrice.value,
|
||||
sellGoldDebit: sellGoldDebit.value,
|
||||
}).formHint
|
||||
)
|
||||
|
||||
const minOrderCash = computed(() => productMinOrderCash(props.ProductItem, isBuy.value))
|
||||
const maxOrderCash = computed(() => productMaxOrderCash(props.ProductItem))
|
||||
const cashValidationMessage = computed(() =>
|
||||
productCashValidationMessage(props.ProductItem, form.value?.goldCash, isBuy.value, usePriceWithoutType)
|
||||
)
|
||||
|
||||
const priceUnavailable = computed(() => {
|
||||
const unit = unitPrice.value
|
||||
return !unit || Number.isNaN(unit) || unit <= 0
|
||||
@@ -324,13 +457,20 @@ const priceUnavailable = computed(() => {
|
||||
const disableTransaction = computed(() =>
|
||||
loading.value ||
|
||||
isStoreClosed() ||
|
||||
!isProductTradeOpen(props.ProductItem, isBuy.value) ||
|
||||
form.value?.goldValue === undefined ||
|
||||
form.value?.goldValue === '' ||
|
||||
form.value?.goldCash === '' ||
|
||||
form.value?.goldCash === undefined ||
|
||||
(isBuy.value ? (form.value?.goldCash ?? 0) < 500000 : (form.value?.goldValue ?? 0) < 0.001) ||
|
||||
(!isBuy.value && sellGoldDebit.value > (walletStore?.walletInfo?.gold ?? 0)) ||
|
||||
(isBuy.value && payablePrice.value > (walletStore?.walletInfo?.cash ?? 0)) ||
|
||||
(isBuy.value
|
||||
? (form.value?.goldCash ?? 0) < minOrderCash.value
|
||||
: (productMinOrderCash(props.ProductItem, false) > 0 || productMaxOrderCash(props.ProductItem) != null
|
||||
? ((form.value?.goldCash ?? 0) < minOrderCash.value || (maxOrderCash.value != null && (form.value?.goldCash ?? 0) > maxOrderCash.value))
|
||||
: (form.value?.goldValue ?? 0) < 0.001)) ||
|
||||
(maxOrderCash.value != null && (form.value?.goldCash ?? 0) > maxOrderCash.value) ||
|
||||
cashValidationMessage.value !== '' ||
|
||||
(!isBuy.value && sellGoldDebit.value > productWalletBalance.value) ||
|
||||
(isBuy.value && payablePrice.value > cashWalletBalance.value) ||
|
||||
priceUnavailable.value)
|
||||
|
||||
function refreshTable() {
|
||||
@@ -424,90 +564,40 @@ async function initSellTable() {
|
||||
// }
|
||||
|
||||
onMounted(async () => {
|
||||
switch (router.currentRoute.value.query.state) {
|
||||
case 'sell':
|
||||
isBuy.value = false
|
||||
break
|
||||
case 'buy':
|
||||
isBuy.value = true
|
||||
break
|
||||
}
|
||||
isBuy.value = resolveProductTradeIsBuy(
|
||||
props?.ProductItem,
|
||||
router.currentRoute.value.query.state?.toString(),
|
||||
)
|
||||
await initPrice()
|
||||
await loadServiceFeeFromSettings()
|
||||
/* await initSellTable()
|
||||
await initBuyTable()*/
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props?.ProductItem?.id, props?.ProductItem?.open_buy, props?.ProductItem?.open_sell],
|
||||
() => {
|
||||
isBuy.value = resolveProductTradeIsBuy(props?.ProductItem, isBuy.value ? 'buy' : 'sell')
|
||||
},
|
||||
)
|
||||
|
||||
const form = ref<Gold>({
|
||||
goldCash: undefined,
|
||||
goldValue: undefined,
|
||||
percentWallet: undefined
|
||||
percentWallet: 0,
|
||||
})
|
||||
const allowedAsciiCodeFloat = [46, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57]
|
||||
const allowedAsciiCodeInt = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57]
|
||||
|
||||
// Function to convert Persian/Farsi numbers to English
|
||||
function convertPersianToEnglish(str: string): string {
|
||||
const persianNumbers = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
|
||||
const englishNumbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
|
||||
const persianDecimal = '٫'
|
||||
const englishDecimal = '.'
|
||||
|
||||
let result = str
|
||||
// Replace Persian numbers with English numbers
|
||||
for (let i = 0; i < persianNumbers.length; i++) {
|
||||
result = result.replace(new RegExp(persianNumbers[i], 'g'), englishNumbers[i])
|
||||
}
|
||||
// Replace Persian decimal separator with English decimal separator
|
||||
result = result.replace(new RegExp(persianDecimal, 'g'), englishDecimal)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Handler for Persian number input
|
||||
function handlePersianNumberInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target && target.value) {
|
||||
const convertedValue = convertPersianToEnglish(target.value)
|
||||
if (convertedValue !== target.value) {
|
||||
form.value.goldValue = convertedValue as any
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(form.value, () => {
|
||||
if (form.value.goldValue) {
|
||||
const goldValueStr = form.value.goldValue.toString()
|
||||
// Convert Persian numbers to English
|
||||
const convertedValue = convertPersianToEnglish(goldValueStr)
|
||||
if (convertedValue !== goldValueStr) {
|
||||
const numericValue = parseFloat(convertedValue)
|
||||
form.value.goldValue = isNaN(numericValue) ? null : numericValue
|
||||
if (!isNaN(numericValue)) {
|
||||
calculateCash(numericValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const lastCharGoldStr: string = goldValueStr
|
||||
const lastCharGoldStr: string = form.value.goldValue.toString()
|
||||
if (!allowedAsciiCodeFloat.includes(lastCharGoldStr.slice(-1).charCodeAt(0))) {
|
||||
form.value.goldValue = isNaN(parseFloat(lastCharGoldStr.slice(0, -1))) ? null : parseFloat(lastCharGoldStr.slice(0, -1))
|
||||
calculateCash(form.value.goldValue)
|
||||
}
|
||||
}
|
||||
if (form.value.goldCash) {
|
||||
const goldCashStr = form.value.goldCash.toString()
|
||||
// Convert Persian numbers to English
|
||||
const convertedValue = convertPersianToEnglish(goldCashStr)
|
||||
if (convertedValue !== goldCashStr) {
|
||||
const numericValue = parseFloat(convertedValue.replaceAll(',', ''))
|
||||
form.value.goldCash = isNaN(numericValue) ? null : numericValue
|
||||
if (!isNaN(numericValue)) {
|
||||
calculateGold(numericValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const lastCharCashStr: string = goldCashStr
|
||||
const lastCharCashStr: string = form.value.goldCash.toString()
|
||||
if (!allowedAsciiCodeInt.includes(lastCharCashStr.slice(-1).charCodeAt(0))) {
|
||||
form.value.goldCash = isNaN(parseFloat(lastCharCashStr.slice(0, -1))) ? null : parseFloat(lastCharCashStr.slice(0, -1))
|
||||
calculateGold(form.value.goldCash)
|
||||
@@ -517,24 +607,23 @@ watch(form.value, () => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadWalletAssets()
|
||||
console.log(userStore.userInfo?.is_legal === 2)
|
||||
if (userStore.userInfo?.is_legal === 0) {
|
||||
noCompleteVisible.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPriceInterval()
|
||||
})
|
||||
|
||||
function percentChange(val: number) {
|
||||
if (val !== 0) {
|
||||
if (isBuy.value) {
|
||||
form.value.goldCash = unitCashCalculator.value * val
|
||||
calculateGold(form.value.goldCash)
|
||||
} else {
|
||||
form.value.goldValue = formatTradeGramDisplay(parseFloat(String(unitGoldCalculator.value * val)))
|
||||
calculateCash(form.value.goldValue)
|
||||
form.value.goldValue = formatTradeGramInputValue(parseFloat(String(unitGoldCalculator.value * val)))
|
||||
if (form.value.goldValue != null) {
|
||||
calculateCash(Number(form.value.goldValue))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
form.value.goldValue = undefined
|
||||
@@ -543,13 +632,10 @@ function percentChange(val: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const unitGoldCalculator = computed(() => {
|
||||
// console.log(walletStore?.walletInfo?.gold / 100)
|
||||
return (walletStore?.walletInfo?.gold ?? 0) / 100
|
||||
})
|
||||
const unitGoldCalculator = computed(() => productWalletBalance.value / 100)
|
||||
|
||||
const unitCashCalculator = computed(() => {
|
||||
return (walletStore?.walletInfo?.cash ?? 0) / 100
|
||||
return cashWalletBalance.value / 100
|
||||
})
|
||||
|
||||
function syncFormWithLatestPrice() {
|
||||
@@ -571,13 +657,13 @@ function syncFormWithLatestPrice() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [unitPrice.value, isBuy.value],
|
||||
() => [unitPrice.value, isBuy.value, props.ProductItem?.id],
|
||||
() => {
|
||||
syncFormWithLatestPrice()
|
||||
}
|
||||
)
|
||||
|
||||
watch([feeDeductFromGold, () => serviceFeePercentage.value], () => {
|
||||
watch([() => serviceFeePercentage.value, inputLock], () => {
|
||||
syncFormWithLatestPrice()
|
||||
})
|
||||
|
||||
@@ -601,12 +687,12 @@ function calculateGold(val: number) {
|
||||
if (!unit || Number.isNaN(unit)) return
|
||||
|
||||
if (val) {
|
||||
form.value.goldValue = formatTradeGramDisplay(calcGramFromLockedRial(val, unit, isBuy.value))
|
||||
form.value.goldValue = formatTradeGramInputValue(calcGramFromLockedRial(val, unit, isBuy.value))
|
||||
} else if (val.toString() === '') {
|
||||
form.value.goldValue = undefined
|
||||
inputLock.value = null
|
||||
} else {
|
||||
form.value.goldValue = 0
|
||||
form.value.goldValue = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,7 +709,10 @@ function useStyle() {
|
||||
else return ' right: 98%; transform: translate(100%, 0);'
|
||||
}
|
||||
|
||||
|
||||
function setIsBuy(val: boolean) {
|
||||
if (val && !isProductBuyOpen(props?.ProductItem)) return
|
||||
if (!val && !isProductSellOpen(props?.ProductItem)) return
|
||||
isBuy.value = val
|
||||
form.value.goldValue = undefined
|
||||
form.value.goldCash = undefined
|
||||
@@ -642,6 +731,7 @@ const formatter = (value: number) => {
|
||||
return `${value}%`
|
||||
}
|
||||
|
||||
|
||||
const price = computed((): Price => <Price>lastPrice.value ?? 0)
|
||||
|
||||
const goldPriceValue = computed(() => unitPrice.value)
|
||||
@@ -649,9 +739,7 @@ const goldPriceValue = computed(() => unitPrice.value)
|
||||
async function initPrice() {
|
||||
try {
|
||||
await getLastPrices()
|
||||
await setIntervalGetAllPrice()
|
||||
loading.value = true
|
||||
price.value = lastPrice.value
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
@@ -664,38 +752,51 @@ function okConfirm() {
|
||||
message.warning(getStoreClosedMessage())
|
||||
return
|
||||
}
|
||||
if (!isProductTradeOpen(props.ProductItem, isBuy.value)) {
|
||||
message.warning(productTradeClosedMessage(isBuy.value))
|
||||
return
|
||||
}
|
||||
if (cashValidationMessage.value) {
|
||||
message.warning(cashValidationMessage.value)
|
||||
return
|
||||
}
|
||||
confirmAction.value = true
|
||||
}
|
||||
|
||||
async function ok() {
|
||||
if (loading.value) {
|
||||
return
|
||||
}
|
||||
if (isStoreClosed()) {
|
||||
message.warning(getStoreClosedMessage())
|
||||
return
|
||||
}
|
||||
if (!isProductTradeOpen(props.ProductItem, isBuy.value)) {
|
||||
message.warning(productTradeClosedMessage(isBuy.value))
|
||||
return
|
||||
}
|
||||
confirmAction.value = false
|
||||
try {
|
||||
loading.value = true
|
||||
await nextTick()
|
||||
infoVisible.value = true
|
||||
await createOrder({
|
||||
direction: isBuy.value ? 1 : 2,
|
||||
price: Math.round(unitPrice.value),
|
||||
amount: normalizeOrderGoldAmount(orderGoldAmount.value),
|
||||
target_cash: calcTradeTargetCash(form.value.goldCash, inputLock.value)
|
||||
?? calcSellTargetCash(
|
||||
form.value.goldCash,
|
||||
form.value.goldValue,
|
||||
unitPrice.value,
|
||||
inputLock.value
|
||||
),
|
||||
// ریال قفل → مبلغ دقیق به سرور میرود (کارمزد از طلا). گرم قفل → target_cash خالی، وزن دقیق معامله میشود.
|
||||
target_cash: calcTradeTargetCash(form.value.goldCash, inputLock.value),
|
||||
abshode_id: props.ProductItem?.id,
|
||||
})
|
||||
await walletStore.getMyWallet()
|
||||
await loadWalletAssets(true)
|
||||
await router.push({ name: 'transactions' })
|
||||
form.value = {
|
||||
goldValue: undefined,
|
||||
goldCash: undefined,
|
||||
percentWallet: undefined
|
||||
percentWallet: 0,
|
||||
}
|
||||
inputLock.value = null
|
||||
// refreshTable()
|
||||
message.success('سفارش شما با موفقیت انجام شد')
|
||||
await fundStore.getMyFund()
|
||||
} catch (e) {
|
||||
@@ -706,11 +807,24 @@ async function ok() {
|
||||
infoVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
/*.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}*/
|
||||
/*.completeButtonStyle{
|
||||
background: blue;
|
||||
}*/
|
||||
.completeButtonStyle .van-dialog__confirm .van-button__content {
|
||||
background-color: #a72b77;
|
||||
background: @App-primary-color;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
@@ -380,55 +380,28 @@
|
||||
<div class="text-center" @click="showLogoutConfirm = false">انصراف</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
<BasicActionSheet v-model:show="wholeBalanceVisible">
|
||||
<BasicActionSheet v-if="!isDesktop" v-model:show="wholeBalanceVisible">
|
||||
<div class="pb-20">
|
||||
<div>
|
||||
<div class="w-full text-center font-bold">موجودی ها</div>
|
||||
<div class="h-30vh overflow-y-auto">
|
||||
<div class="mt-5">
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="مجموعه کل دارایی طلایی"
|
||||
is-line
|
||||
:value="balanceLabel"
|
||||
/>
|
||||
<a-divider class="!my-1" />
|
||||
<BasicInfoItem
|
||||
v-if="countBasedInventory > 0"
|
||||
class="flex justify-between"
|
||||
title="تعداد محصولات"
|
||||
is-line
|
||||
:value="`${countBasedInventory} عدد`"
|
||||
/>
|
||||
<a-divider v-if="countBasedInventory > 0" class="!my-1" />
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="دارایی کیف پول ریالی"
|
||||
is-line
|
||||
:value="usePrice(assetsSummary?.cash_wallet ?? walletStore.walletInfo?.cash ?? 0)"
|
||||
/>
|
||||
<a-divider class="!my-1" />
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="دارایی کیف پول طلایی"
|
||||
is-line
|
||||
:value="(walletStore.walletInfo?.count_gold_wallet ?? 0) + ' گرم'"
|
||||
/>
|
||||
<a-divider class="!my-1" />
|
||||
<!-- <BasicInfoItem class="flex justify-between" title="دارایی کیف پول(به گرم)" is-line
|
||||
:value="((walletStore.walletInfo?.cash?? 0) / (goldVal?? 0)).toFixed(2)+ ' گرم'" />
|
||||
<a-divider class="!my-1" />-->
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="ارزش کل دارایی شما"
|
||||
is-line
|
||||
:value="usePrice(totalRialDisplay)"
|
||||
/>
|
||||
</div>
|
||||
<WholeBalanceContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicActionSheet>
|
||||
<BasicModal
|
||||
v-else
|
||||
v-model:visible="wholeBalanceVisible"
|
||||
title="موجودی ها"
|
||||
width="480px"
|
||||
custom-footer
|
||||
>
|
||||
<WholeBalanceContent />
|
||||
<template #footer>
|
||||
<div></div>
|
||||
</template>
|
||||
</BasicModal>
|
||||
<BasicModal
|
||||
v-model:visible="openModal"
|
||||
custom-footer
|
||||
@@ -489,7 +462,7 @@ import type { PInventory, Product } from '@/models/products'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { resolveAssetsRialValue } from '@/utils/productAssets'
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { message, Modal, Divider } from 'ant-design-vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
@@ -543,6 +516,50 @@ const totalRialDisplay = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
/** محتوای مشترک «موجودیها» برای اکشنشیت موبایل و مودال دسکتاپ */
|
||||
const WholeBalanceContent = () => (
|
||||
<div class="mt-5">
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="مجموعه کل دارایی طلایی"
|
||||
is-line
|
||||
value={balanceLabel.value}
|
||||
/>
|
||||
<Divider class="!my-1" />
|
||||
{countBasedInventory.value > 0 && (
|
||||
<>
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="تعداد محصولات"
|
||||
is-line
|
||||
value={`${countBasedInventory.value} عدد`}
|
||||
/>
|
||||
<Divider class="!my-1" />
|
||||
</>
|
||||
)}
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="دارایی کیف پول ریالی"
|
||||
is-line
|
||||
value={usePrice(assetsSummary.value?.cash_wallet ?? walletStore.walletInfo?.cash ?? 0)}
|
||||
/>
|
||||
<Divider class="!my-1" />
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="دارایی کیف پول طلایی"
|
||||
is-line
|
||||
value={(walletStore.walletInfo?.count_gold_wallet ?? 0) + ' گرم'}
|
||||
/>
|
||||
<Divider class="!my-1" />
|
||||
<BasicInfoItem
|
||||
class="flex justify-between"
|
||||
title="ارزش کل دارایی شما"
|
||||
is-line
|
||||
value={usePrice(totalRialDisplay.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPriceInterval()
|
||||
})
|
||||
@@ -844,7 +861,7 @@ onMounted(async () => {
|
||||
update()
|
||||
const hasSeenModal = localStorage.getItem('hasSeenIntroModal')
|
||||
if (!hasSeenModal) {
|
||||
openModal.value = true
|
||||
// openModal.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-120 flex flex-col">
|
||||
<div class="text-center">انتخاب آدرس</div>
|
||||
<div class="overflow-y-scroll h-85">
|
||||
<div class="flex flex-col" :class="hideTitle ? 'max-h-[60vh]' : 'h-120'">
|
||||
<div v-if="!hideTitle" class="text-center">انتخاب آدرس</div>
|
||||
<div class="overflow-y-scroll" :class="hideTitle ? 'flex-1 min-h-0 max-h-[48vh]' : 'h-85'">
|
||||
<ARadioGroup v-model:value="selectedAddressID">
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
@@ -33,9 +33,13 @@ import type { Address } from '@/models/address'
|
||||
interface Props {
|
||||
addresses?: Address[]
|
||||
selectedAddressId?: number
|
||||
/** وقتی داخل مودال دسکتاپ است، عنوان تکراری نمایش داده نشود */
|
||||
hideTitle?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hideTitle: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
addAddress: []
|
||||
|
||||
@@ -51,25 +51,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BasicActionSheet v-model:show="showAddress">
|
||||
<BasicModal
|
||||
v-model:visible="showAddress"
|
||||
title="انتخاب آدرس"
|
||||
width="520px"
|
||||
custom-footer
|
||||
>
|
||||
<PhysicalReceiveAddressPicker
|
||||
v-model:selected-address-id="selectedAddressID"
|
||||
:addresses="address"
|
||||
hide-title
|
||||
@add-address="addAddressVisible = true; showAddress = false"
|
||||
@submit="handelsubmit"
|
||||
/>
|
||||
</BasicActionSheet>
|
||||
<template #footer>
|
||||
<div></div>
|
||||
</template>
|
||||
</BasicModal>
|
||||
|
||||
<BasicActionSheet v-model:show="addAddressVisible">
|
||||
<BasicModal
|
||||
v-model:visible="addAddressVisible"
|
||||
title="افزودن آدرس"
|
||||
width="520px"
|
||||
custom-footer
|
||||
>
|
||||
<New_EditAddress v-if="addAddressVisible" :visible="addAddressVisible" @onOk="handleAddress" />
|
||||
</BasicActionSheet>
|
||||
<template #footer>
|
||||
<div></div>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
|
||||
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
||||
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
|
||||
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
import New_EditAddress from '@/views/base/address/components/New_EditAddress.vue'
|
||||
import PhysicalReceiveCartRow from '../components/PhysicalReceiveCartRow.vue'
|
||||
|
||||
@@ -55,9 +55,22 @@ const { isDesktop } = usePhysicalReceiveShell()
|
||||
|
||||
.physical-receive-page-root--desktop {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* در دسکتاپ ارتفاع تا انتهای صفحه کشیده شود */
|
||||
.physical-receive-page-root--desktop > div,
|
||||
.physical-receive-page-root--desktop > div > div {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.physical-receive-page-root--desktop > div > div > :deep(*) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
هنوز درخواستی ثبت نشده است.
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<Teleport v-if="!isDesktop" to="body">
|
||||
<div class="app-dock-add-bar" @click="openAdd()">
|
||||
<BasicIcon icon="vuesax-linear:add" />
|
||||
<div>افزودن درخواست جدید</div>
|
||||
@@ -24,6 +24,8 @@
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import RequestItem from '../components/RequestItem.vue'
|
||||
import { usePhysicalReceiveListContext } from '../composables/usePhysicalReceiveList'
|
||||
import { usePhysicalReceiveShellContext } from '../composables/usePhysicalReceiveShell'
|
||||
|
||||
const { requestItems, openAdd } = usePhysicalReceiveListContext()
|
||||
const { isDesktop } = usePhysicalReceiveShellContext()
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Teleport v-if="!isDesktop" to="body">
|
||||
<div
|
||||
v-if="!addTriggerVisible"
|
||||
class="app-dock-add-bar"
|
||||
@@ -40,6 +40,9 @@ import ListAnim from '@/components/ListAnim.vue'
|
||||
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
|
||||
import Item from '../components/Item.vue'
|
||||
import { usePriceTriggerPageContext } from '../composables/usePriceTriggerPage'
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
|
||||
const {
|
||||
items,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<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: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recovery-sites-list__name {
|
||||
color: var(--app-text-primary, #111827);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.recovery-sites-list__url {
|
||||
overflow: hidden;
|
||||
color: var(--app-text-secondary, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
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,73 @@
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
type ComputedRef,
|
||||
type InjectionKey,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
import {
|
||||
fetchTrackingSettings,
|
||||
resolveTrackingSystems,
|
||||
type TrackingSystemSite,
|
||||
} from '@/utils/trackingSystemsSettings'
|
||||
|
||||
export interface RecoverySitesPageContext {
|
||||
sites: Ref<TrackingSystemSite[]>
|
||||
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<TrackingSystemSite[]>([])
|
||||
const loading = ref(false)
|
||||
const isEmpty = computed(() => !loading.value && sites.value.length === 0)
|
||||
|
||||
async function loadSites() {
|
||||
loading.value = true
|
||||
try {
|
||||
sites.value = resolveTrackingSystems(await fetchTrackingSettings(true))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
sites.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadSites()
|
||||
})
|
||||
|
||||
function openSite(url: string) {
|
||||
const normalized = normalizeExternalUrl(url)
|
||||
if (normalized) 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,76 @@
|
||||
<template>
|
||||
<div class="drecovery">
|
||||
<div class="drecovery__container">
|
||||
<DesktopPageTopbar eyebrow="خدمات" title="سایتهای ریگیری" show-back />
|
||||
|
||||
<section class="drecovery-surface">
|
||||
<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 DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
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-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,26 @@
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</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>
|
||||
@@ -12,12 +12,14 @@ import type { FormInstance } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { updateMe, updatePass } from '@/api/me'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
|
||||
|
||||
export interface UserMenuAction {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
onClick: () => void
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export interface UserPageContext {
|
||||
@@ -59,6 +61,7 @@ export function useUserPage() {
|
||||
const userStore = useUserStore()
|
||||
const editPassVisible = ref(false)
|
||||
const editPasswordVisible = ref(false)
|
||||
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
|
||||
|
||||
const isLegal = computed(() => {
|
||||
const result = { title: '', color: '', icon: '' }
|
||||
@@ -80,32 +83,45 @@ export function useUserPage() {
|
||||
|
||||
const shareButtonLabel = computed(() => 'اشتراک گذاری')
|
||||
|
||||
const menuActions = computed<UserMenuAction[]>(() => [
|
||||
{
|
||||
id: 'password',
|
||||
label: 'تغییر رمز عبور',
|
||||
icon: 'vuesax-linear:lock',
|
||||
onClick: () => { editPasswordVisible.value = true },
|
||||
},
|
||||
{
|
||||
id: 'notify',
|
||||
label: 'هشدار قیمت',
|
||||
icon: 'vuesax-linear:bill',
|
||||
onClick: () => router.push({ name: 'notifyTrigger' }),
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'درباره ما',
|
||||
icon: 'vuesax-linear:info-circle',
|
||||
onClick: () => router.push({ name: 'about' }),
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
label: 'قوانین و مقررات',
|
||||
icon: 'vuesax-linear:task',
|
||||
onClick: () => router.push({ name: 'rules' }),
|
||||
},
|
||||
])
|
||||
const menuActions = computed<UserMenuAction[]>(() =>
|
||||
[
|
||||
{
|
||||
id: 'password',
|
||||
label: 'تغییر رمز عبور',
|
||||
icon: 'vuesax-linear:lock',
|
||||
onClick: () => { editPasswordVisible.value = true },
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'notify',
|
||||
label: 'هشدار قیمت',
|
||||
icon: 'vuesax-linear:bill',
|
||||
onClick: () => router.push({ name: 'notifyTrigger' }),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'درباره ما',
|
||||
icon: 'vuesax-linear:info-circle',
|
||||
onClick: () => router.push({ name: 'about' }),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'recoverySites',
|
||||
label: 'سایتهای ریگیری',
|
||||
icon: 'vuesax-linear:global',
|
||||
onClick: () => router.push({ name: 'recoverySites' }),
|
||||
visible: trackingSystemsEnabled.value,
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
label: 'قوانین و مقررات',
|
||||
icon: 'vuesax-linear:task',
|
||||
onClick: () => router.push({ name: 'rules' }),
|
||||
visible: true,
|
||||
},
|
||||
].filter((action) => action.visible !== false),
|
||||
)
|
||||
|
||||
function setForm() {
|
||||
if (userStore.userInfo) {
|
||||
|
||||
@@ -108,6 +108,19 @@
|
||||
<div>درباره ما</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton
|
||||
v-if="trackingSystemsEnabled"
|
||||
class="mt-2 w-full"
|
||||
icon=""
|
||||
iconSize="18"
|
||||
size="large"
|
||||
@click="$router.push({ name: 'recoverySites' })"
|
||||
>
|
||||
<div class="flex">
|
||||
<BasicIcon icon="vuesax-linear:global" class="absolute right-8" />
|
||||
<div>سایتهای ریگیری</div>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<!-- <BasicButton-->
|
||||
<!-- class="mt-2 w-full"-->
|
||||
<!-- icon=""-->
|
||||
@@ -214,6 +227,7 @@ import { useWalletStore } from '@/store/walletStore'
|
||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useWindowScroll } from '@vueuse/core/index'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useUserPageContext } from '../composables/useUserPage'
|
||||
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
|
||||
@@ -223,6 +237,7 @@ const walletStore = useWalletStore()
|
||||
const showLogoutConfirm = ref<boolean>()
|
||||
const logoutLoading = ref<boolean>(false)
|
||||
const { y } = useWindowScroll()
|
||||
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
|
||||
|
||||
const { editPassVisible, editPasswordVisible, copyCode, shareCode } = useUserPageContext()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user