feat: align fee summary, special pricing and support chat with modular

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 17:57:22 +03:30
co-authored by Cursor
parent e9c5830d4e
commit e01192fb5e
11 changed files with 292 additions and 49 deletions
+148 -23
View File
@@ -1,55 +1,59 @@
<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">
<div class="trade-fee-summary">
<div class="trade-fee-summary__quote">
<div class="trade-fee-summary__label">مظنه هر {{ weightUnit }}</div>
<div class="trade-fee-summary__value trade-fee-summary__value--strong">
<span>{{ formattedUnitPrice }}</span>
<span class="text-xs mr-1 font-normal">ریال</span>
<span class="trade-fee-summary__unit">ریال</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 }"
class="trade-fee-summary__section"
:class="{ 'trade-fee-summary__section--divided': index > 0 }"
>
<div class="text-sm font-bold text-gray-800 mb-2">{{ section.heading }}</div>
<div class="trade-fee-summary__heading">{{ section.heading }}</div>
<div
v-for="(row, rowIndex) in section.rows"
:key="`${section.heading}-${rowIndex}`"
class="flex justify-between items-center py-1"
class="trade-fee-summary__row"
:class="{
'border-t border-dashed border-gray-300 mt-1 pt-2': row.dividerBefore,
'font-bold text-base': row.bold,
'trade-fee-summary__row--divider': row.dividerBefore,
'trade-fee-summary__row--bold': 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>
<div
class="trade-fee-summary__label"
:class="{ 'trade-fee-summary__label--strong': row.bold }"
>
<span v-if="row.plus" class="trade-fee-summary__sign trade-fee-summary__sign--plus">+</span>
<span v-else-if="row.minus && !row.bold" class="trade-fee-summary__sign trade-fee-summary__sign--minus">−</span>
{{ row.label }}
</div>
<div
class="text-left text-sm"
class="trade-fee-summary__value"
:class="{
'text-red-600': row.minus && !row.bold,
'text-green-700': row.plus,
'trade-fee-summary__value--minus': row.minus && !row.bold,
'trade-fee-summary__value--plus': row.plus,
'trade-fee-summary__value--strong': row.bold,
}"
>
<template v-if="row.gram != null">
<span>{{ formatTradeGramDisplay(row.gram) }}</span>
<span class="text-xs mr-1">{{ weightUnit }}</span>
<span class="trade-fee-summary__unit">{{ 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>
<span class="trade-fee-summary__unit">ریال</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">
<div v-if="section.footnote" class="trade-fee-summary__footnote">
{{ section.footnote }}
</div>
</div>
@@ -68,7 +72,7 @@ import {
const props = defineProps<{
isBuy: boolean
inputLock: TradeInputLock
displayTradeGoldAmount: number
displayTradeGoldAmount: number | string
weightUnit: string
unitPriceRial: number
feeInGrams: number
@@ -80,7 +84,7 @@ const props = defineProps<{
}>()
const formattedUnitPrice = computed(() =>
usePriceWithoutType(Math.round(props.unitPriceRial))
usePriceWithoutType(Math.round(props.unitPriceRial)),
)
function formatRial(value: number) {
@@ -97,6 +101,127 @@ const breakdown = computed(() =>
payablePrice: props.payablePrice,
unitPriceRial: props.unitPriceRial,
sellGoldDebit: props.sellGoldDebit,
})
}),
)
</script>
<style scoped lang="less">
.trade-fee-summary {
width: 100%;
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--app-surface-inset, var(--app-surface));
border: 1px solid var(--app-border);
}
.trade-fee-summary__quote {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--app-border);
}
.trade-fee-summary__section {
margin-top: 0.75rem;
&--divided {
padding-top: 0.75rem;
border-top: 1px solid var(--app-border);
}
}
.trade-fee-summary__heading {
margin-bottom: 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--app-text-primary);
text-align: right;
}
.trade-fee-summary__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.25rem 0;
&--divider {
margin-top: 0.25rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--app-border);
}
&--bold {
font-size: 0.9375rem;
font-weight: 700;
}
}
.trade-fee-summary__label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--app-text-secondary);
text-align: right;
&--strong {
font-weight: 700;
color: var(--app-text-primary);
}
}
.trade-fee-summary__value {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--app-text-primary);
text-align: left;
direction: ltr;
&--strong {
font-weight: 700;
}
&--minus {
color: var(--app-down, #ef4444);
}
&--plus {
color: var(--app-up, #22c55e);
}
}
.trade-fee-summary__unit {
font-size: 0.6875rem;
font-weight: 500;
color: var(--app-text-muted);
}
.trade-fee-summary__sign {
margin-left: 0.25rem;
&--plus {
color: var(--app-up, #22c55e);
}
&--minus {
color: var(--app-down, #ef4444);
}
}
.trade-fee-summary__footnote {
margin-top: 0.5rem;
padding: 0.5rem 0.625rem;
border-radius: 0.625rem;
background: var(--app-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--app-border);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.7;
color: var(--app-text-secondary);
text-align: right;
}
</style>
+7 -2
View File
@@ -1,6 +1,6 @@
import { computed, ref } from 'vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import {
buildDepositSmsUrl,
buildSupportPhoneUrl,
@@ -43,10 +43,15 @@ const CHAT_PICKER_ENTRY: ResolvedSupportMessengerEntry = {
export function useSupportMessenger() {
const settingsStore = useSettingsStore()
const router = useRouter()
const route = useRoute()
const supportPickerOpen = supportMessengerPickerOpen
const supportPickerIntent = ref<SupportPickerIntent>('plain')
const isChatEnabled = computed(() => isUserSupportChatEnabled())
/** چت فقط بعد از لاگین — روی صفحه ورود در picker نیاید */
const isChatEnabled = computed(() => {
void route.name
return isUserSupportChatEnabled()
})
const legacySupportLink = computed(() => '')
+3 -2
View File
@@ -3,7 +3,7 @@ import { useWebSocket } from '@vueuse/core'
import { useUserStore } from '@/store/userStore'
import { useSettingsStore } from '@/store/settingsStore'
import { startChat } from '@/api/chat'
import { createUuid } from '@/utils/index'
import { createUuid, getToken } from '@/utils/index'
/** true/1/yes/activate */
export function isSettingFlagOn(value: unknown): boolean {
@@ -11,8 +11,9 @@ export function isSettingFlagOn(value: unknown): boolean {
return v === 'true' || v === '1' || v === 'yes' || v === 'activate'
}
/** نمایش چت برای کاربر نهایی: سوییچ دیپلوی + سوییچ مشتری */
/** نمایش چت برای کاربر نهایی: لاگین + سوییچ دیپلوی + سوییچ مشتری */
export function isUserSupportChatEnabled(): boolean {
if (!getToken()) return false
const settings = useSettingsStore()
return (
isSettingFlagOn(settings.getSettingByKey('show_to_users_chat')) &&
+2 -1
View File
@@ -7,6 +7,7 @@ import {
priceWssEnabled,
shouldUsePricePolling,
} from '@/utils/priceSocketState'
import { applyBoardSpecialToLastPayload } from '@/utils/specialPricing'
const LAST_INTERVAL_MS = 10000
@@ -161,7 +162,7 @@ export async function getLastPrices(): Promise<boolean> {
}
export function applyLastPricePayload(dataLast: Price) {
const normalized = normalizePriceResponse(dataLast)
const normalized = normalizePriceResponse(applyBoardSpecialToLastPayload(dataLast as any) as Price)
const nextBuy = getTradeBuyPrice(normalized)
const prevBuy = getTradeBuyPrice(lastPrice.value)
+13 -1
View File
@@ -9,6 +9,10 @@ import {
priceStreamLifecycle,
priceWssEnabled,
} from '@/utils/priceSocketState'
import {
applyBoardSpecialToLastPayload,
setSpecialPricingPayload,
} from '@/utils/specialPricing'
export {
priceSocketConnected,
@@ -326,7 +330,7 @@ function handleLastPayload(payload: unknown) {
return
}
const last = payload as Price
const last = applyBoardSpecialToLastPayload(payload as Price)
if (last.price_last == null && last.price == null && last.buy == null) {
return
}
@@ -413,6 +417,14 @@ function bindStreamEvents(source: EventSource) {
}
})
source.addEventListener('special-pricing', (event) => {
try {
setSpecialPricingPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Special-pricing SSE parse failed', error)
}
})
source.addEventListener('ping', () => {
touchStreamActivity()
})
+29 -3
View File
@@ -5,7 +5,7 @@ export type ProductCashFeeSource = {
cash_fee_sell_is_percent?: boolean | null
}
/** 0.5 => 0.5% ، 5 => 5% */
/** 0.5 => 0.5% ، 5 => 5% — همیشه عدد درصد */
export function resolveCashFeePercentRatio(percentValue?: number | null) {
const offset = Number(percentValue ?? 0)
if (!offset || !Number.isFinite(offset)) {
@@ -15,6 +15,11 @@ export function resolveCashFeePercentRatio(percentValue?: number | null) {
return offset / 100
}
/** @deprecated از resolveCashFeePercentRatio استفاده کنید */
export function resolveFeePercentRatio(percentValue?: number | null) {
return resolveCashFeePercentRatio(percentValue)
}
export function resolveProductCashFeeRial(
feeValue: number | null | undefined,
isPercent: boolean | null | undefined,
@@ -38,17 +43,38 @@ export function resolveProductCashFeeRial(
}
export function resolveProductCashFeeBuyRial(product: ProductCashFeeSource, basePriceRial: number) {
return resolveProductCashFeeRial(
const baseFee = resolveProductCashFeeRial(
product.cash_fee_buy,
product.cash_fee_buy_is_percent,
basePriceRial,
)
const special = Number((product as any).special_profit_buy ?? 0)
return baseFee + (Number.isFinite(special) ? Math.round(special) : 0)
}
export function resolveProductCashFeeSellRial(product: ProductCashFeeSource, basePriceRial: number) {
return resolveProductCashFeeRial(
const baseFee = resolveProductCashFeeRial(
product.cash_fee_sell,
product.cash_fee_sell_is_percent,
basePriceRial,
)
const special = Number((product as any).special_profit_sell ?? 0)
return baseFee + (Number.isFinite(special) ? Math.round(special) : 0)
}
export function formatProductCashFeeLabel(
feeValue: number | null | undefined,
isPercent?: boolean | null,
) {
const raw = Number(feeValue ?? 0)
if (!raw) {
return '۰'
}
if (isPercent) {
const text = Number.isInteger(raw) ? String(raw) : String(parseFloat(raw.toFixed(4)))
return `${text}%`
}
return `${raw} ریال`
}
+7 -3
View File
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import { getSettings } from '@/api/configuration'
import { useSettingsStore } from '@/store/settingsStore'
export const serviceFeePercentage = ref<number>(0.01)
@@ -68,8 +68,12 @@ function feeRate() {
export async function loadServiceFeeFromSettings() {
try {
const settings = await getSettings()
if (settings && Array.isArray(settings)) {
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
const settings = settingsStore.settings
if (settings?.length) {
const feeSetting = settings.find(
(s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true
)
+80
View File
@@ -0,0 +1,80 @@
import { ref } from 'vue'
export type SpecialPricingRule = {
product_id?: number
package_id: number
title: string
profit_buy: number
profit_sell: number
show_product: boolean
}
export type SpecialPricingPayload = {
enabled?: boolean
products?: SpecialPricingRule[]
board?: SpecialPricingRule | null
}
const state = ref<{
enabled: boolean
byProduct: Record<number, SpecialPricingRule>
board: SpecialPricingRule | null
}>({
enabled: false,
byProduct: {},
board: null,
})
export function setSpecialPricingPayload(payload?: SpecialPricingPayload | null) {
if (!payload || !payload.enabled) {
state.value = { enabled: false, byProduct: {}, board: null }
return
}
const byProduct: Record<number, SpecialPricingRule> = {}
for (const row of payload.products ?? []) {
const pid = Number(row.product_id ?? 0)
if (pid > 0) byProduct[pid] = row
}
state.value = {
enabled: true,
byProduct,
board: payload.board ?? null,
}
}
export function getSpecialPricingState() {
return state
}
/** اگر سرور قبلاً special_pricing نزده، اختلاف تابلو را روی last اعمال کن */
export function applyBoardSpecialToLastPayload<T extends Record<string, any>>(payload: T): T {
if (!payload || typeof payload !== 'object') return payload
if (payload.special_pricing) return payload
const board = state.value.board
if (!state.value.enabled || !board) return payload
const buyExtra = Number(board.profit_buy ?? 0)
const sellExtra = Number(board.profit_sell ?? 0)
if (buyExtra <= 0 && sellExtra <= 0) return payload
const next = { ...payload } as T & Record<string, any>
if (Number(next.buy ?? 0) > 0 && buyExtra > 0) {
next.buy = Number(next.buy) + buyExtra
}
if (Number(next.sell ?? 0) > 0 && sellExtra > 0) {
next.sell = Math.max(0, Number(next.sell) - sellExtra)
}
if (Number(next.price_last ?? 0) > 0 && buyExtra > 0) {
next.price_last = Number(next.price_last) + buyExtra
}
next.special_pricing = {
package_id: board.package_id,
title: board.title,
profit_buy: buyExtra,
profit_sell: sellExtra,
}
return next
}
+2 -5
View File
@@ -1,5 +1,4 @@
import { ref } from 'vue'
import { getSettings } from '@/api/configuration'
import type { DepositGuideVars } from '@/utils/depositGuideText'
import {
DEFAULT_DEPOSIT_GUIDE_TEXT,
@@ -366,10 +365,8 @@ export async function loadSupportMessengerFromSettings(forceSettingsRefresh = fa
await settingsStore.fetchSettings()
}
const settings = await getSettings()
if (!settings || !Array.isArray(settings)) return
settingsStore.mergeSettings(settings)
const settings = settingsStore.settings
if (!settings?.length) return
supportMessengerSettings.value = {
messengers: resolveSupportMessengersFromSettings(settings),
-8
View File
@@ -1,4 +1,3 @@
import { getSettings } from '@/api/configuration'
import type { AppSetting } from '@/store/settingsStore'
import { useSettingsStore } from '@/store/settingsStore'
@@ -90,13 +89,6 @@ export async function fetchTrackingSettings(force = false): Promise<AppSetting[]
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
})()
@@ -21,7 +21,7 @@ export const TRANSACTIONS_PAGE_KEY: InjectionKey<TransactionsPageContext> = Symb
export function useTransactionsPageProvider() {
const isDesktop = useDesktopBreakpoint()
const pagination = ref<TableEngineInput>({ filters: [], limit: 7, page: 0 })
const pagination = ref<TableEngineInput>({ filters: [], limit: 10, page: 0 })
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const showFilter = ref(false)