feat: align fee summary, special pricing and support chat with modular
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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} ریال`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user