fix kashkool

This commit is contained in:
2026-06-29 22:43:28 +03:30
parent be17631109
commit 8b855e5ee4
150 changed files with 11974 additions and 4445 deletions
-158
View File
@@ -1,158 +0,0 @@
import {
buildSupportMessengerUrl,
loadSupportMessengerFromSettings,
supportMessengerSettings,
} from '@/utils/supportMessenger'
const STORAGE_KEY = 'ghatreh_whatsapp_button_position'
const DRAG_THRESHOLD = 6
const WIDTH = 60
const HEIGHT = 60
const DEFAULT_BOTTOM_OFFSET = 90
const DEFAULT_RIGHT_OFFSET = 20
type FabPosition = { x: number; y: number }
function getViewportWidth() {
return Math.min(window.innerWidth, 426)
}
function getHorizontalOffset() {
const vw = window.innerWidth
const contentWidth = getViewportWidth()
return Math.max(0, (vw - contentWidth) / 2)
}
function getDefaultPosition(): FabPosition {
const vh = window.innerHeight
const offsetX = getHorizontalOffset()
const contentWidth = getViewportWidth()
return {
x: offsetX + contentWidth - WIDTH - DEFAULT_RIGHT_OFFSET,
y: vh - DEFAULT_BOTTOM_OFFSET - HEIGHT,
}
}
function loadPosition(): FabPosition | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as FabPosition
if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') {
return parsed
}
} catch {
// ignore
}
return null
}
function savePosition(pos: FabPosition) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
}
function clampPosition(pos: FabPosition): FabPosition {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: Math.max(0, Math.min(vw - WIDTH, pos.x)),
y: Math.max(0, Math.min(vh - HEIGHT, pos.y)),
}
}
function applyPosition(button: HTMLElement, pos: FabPosition) {
button.style.position = 'fixed'
button.style.bottom = 'auto'
button.style.right = 'auto'
button.style.left = `${pos.x}px`
button.style.top = `${pos.y}px`
button.style.touchAction = 'none'
}
function updateWhatsappHref(button: HTMLAnchorElement) {
const link = buildSupportMessengerUrl(
supportMessengerSettings.value.messenger,
supportMessengerSettings.value.messengerId
)
button.href = link || '#'
button.style.display = 'flex'
}
export function initWhatsappButtonDrag() {
const button = document.getElementById('whatsapp-button') as HTMLAnchorElement | null
if (!button) return
let pos = clampPosition(loadPosition() ?? getDefaultPosition())
applyPosition(button, pos)
let pointerId: number | null = null
let startX = 0
let startY = 0
let originX = 0
let originY = 0
let hasMoved = false
let dragging = false
function onResize() {
pos = clampPosition(pos)
applyPosition(button!, pos)
savePosition(pos)
}
function onPointerDown(event: PointerEvent) {
dragging = true
hasMoved = false
pointerId = event.pointerId
startX = event.clientX
startY = event.clientY
originX = pos.x
originY = pos.y
button!.classList.add('is-dragging')
button!.setPointerCapture(event.pointerId)
event.preventDefault()
}
function onPointerMove(event: PointerEvent) {
if (!dragging || pointerId !== event.pointerId) return
const dx = event.clientX - startX
const dy = event.clientY - startY
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
hasMoved = true
}
pos = clampPosition({ x: originX + dx, y: originY + dy })
applyPosition(button!, pos)
}
function finishPointer(event: PointerEvent) {
if (!dragging || pointerId !== event.pointerId) return
dragging = false
pointerId = null
button!.classList.remove('is-dragging')
savePosition(pos)
const clicked = !hasMoved
hasMoved = false
if (clicked && button!.href && button!.href !== '#') {
window.open(button!.href, '_blank', 'noopener,noreferrer')
}
}
button.addEventListener('pointerdown', onPointerDown)
button.addEventListener('pointermove', onPointerMove)
button.addEventListener('pointerup', finishPointer)
button.addEventListener('pointercancel', finishPointer)
button.addEventListener('click', (event) => event.preventDefault())
window.addEventListener('resize', onResize)
void loadSupportMessengerFromSettings().then(() => {
updateWhatsappHref(button)
})
}
+25 -6
View File
@@ -20,14 +20,28 @@ export const olderPrice = ref<number | undefined>(0)
let lastPriceSourceAt = 0
let consecutivePriceFetchFailures = 0
/** buy/sell نمایش و معامله — همان buy/sell/price_last از /api/price/last */
export function getTradeBuyPrice(p?: Price | null) {
return p?.buy ?? p?.price_last ?? p?.up?.buy ?? 0
}
export function getTradeSellPrice(p?: Price | null) {
return p?.sell ?? p?.up?.sell ?? 0
}
/** up جدا نگه داشته می‌شود؛ buy/sell/price_last از lastPrice سرور دست‌نخورده می‌ماند */
export function normalizePriceResponse(data: Price): Price {
return data
}
function hasValidPricePayload(data?: Price | null): boolean {
if (!data) {
return false
}
return (
Number(data.buy ?? data.up?.buy ?? 0) > 0
|| Number(data.sell ?? data.up?.sell ?? 0) > 0
getTradeBuyPrice(data) > 0
|| getTradeSellPrice(data) > 0
|| Number(data.price_last ?? data.price ?? 0) > 0
)
}
@@ -147,13 +161,18 @@ export async function getLastPrices(): Promise<boolean> {
}
export function applyLastPricePayload(dataLast: Price) {
if (lastPrice.value?.price_last != dataLast.price_last) {
const normalized = normalizePriceResponse(dataLast)
if (lastPrice.value?.price_last != normalized.price_last) {
olderPrice.value = lastPrice.value?.price_last
}
lastPrice.value = dataLast
price.value = dataLast
lastPrice.value = normalized
price.value = {
...normalized,
buy: getTradeBuyPrice(normalized),
sell: getTradeSellPrice(normalized),
}
if (hasValidPricePayload(dataLast)) {
if (hasValidPricePayload(normalized)) {
markPriceSourceReceived()
}
}
+7 -9
View File
@@ -189,16 +189,14 @@ async function runFallbackPoll() {
const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import(
'@/utils/price'
)
const { getProducts } = await import('@/api/products')
const { applyProductsListFromStream: applyList } = await import('@/utils/productStreamBridge')
const [lastResult, products] = await Promise.allSettled([
getLastPrices(),
getProducts(),
])
const { refreshProductsPageData } = await import(
'@/views/base/products/composables/useProductsPageData'
)
if (products.status === 'fulfilled' && Array.isArray(products.value) && products.value.length) {
applyList(products.value)
}
const [lastResult] = await Promise.allSettled([
getLastPrices(),
refreshProductsPageData(),
])
const priceOk =
lastResult.status === 'fulfilled'
+71
View File
@@ -0,0 +1,71 @@
import type { Product } from '@/models/products'
import type { WalletInventoryCard, WalletMetalTotal } from '@/models/walletAssets'
import { resolveProductImageSrc } from '@/utils/productImage'
export function fundPageInventoryCards<T extends { quantity?: number }>(cards: T[]): T[] {
return cards.filter((card) => Number(card.quantity ?? 0) > 0)
}
export function resolveSummaryMetalWeight(
metal: { total_weight: number },
_cards?: Array<{ metal_id?: number; quantity?: number }>
): number {
return Number(metal.total_weight ?? 0)
}
export function enrichInventoryCardsWithCatalog<T extends WalletInventoryCard>(
cards: T[],
catalog: Product[]
): T[] {
return cards.map((card) => {
const fromCatalog = catalog.find((product) => product.id === card.product_id)
if (!fromCatalog) {
return card
}
return {
...card,
file: card.file ?? fromCatalog.file ?? null,
}
})
}
export function getProductDisplayImage(product?: {
group_type?: string | null
title?: string | null
file?: { src?: string | null } | null
img?: string | null
} | null): string {
return resolveProductImageSrc(product)
}
export function formatMetalTotal(
metal: WalletMetalTotal,
inventoryCards: WalletInventoryCard[]
): string {
return `${resolveSummaryMetalWeight(metal, inventoryCards)}${metal.unit}`
}
export function resolveAssetsRialValue(
summary: {
total_rial_value?: number
assets_rial_value?: number
cash_wallet?: number
} | null | undefined,
liveGoldPrice = 0
): number {
const total = Number(summary?.total_rial_value ?? 0)
if (total > 0) {
return total
}
const cash = Number(summary?.cash_wallet ?? 0)
const assetsRial = Number(summary?.assets_rial_value ?? 0)
if (assetsRial > 0) {
return cash + assetsRial
}
const metalWeight = (summary as { metal_totals?: Array<{ total_weight?: number }> } | null)
?.metal_totals?.[0]?.total_weight
return cash + Number(metalWeight ?? 0) * liveGoldPrice
}
+103
View File
@@ -0,0 +1,103 @@
import type { Product } from '@/models/products'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
/**
* قیمت‌گیری ماژولار محصول در حالت فروشگاهی (is_store=true).
*
* منبع حقیقت قیمت و وضعیت نمایش، سرور است: `/customer/product/list` و رویداد SSE
* `list` هر دو قیمت هر محصول (`price`/`price_profit`) و `show` را محاسبه‌شده می‌فرستند.
* این ماژول فقط همان قیمت سرور را می‌خواند و فقط برای آبشده وقتی قیمت سرور صفر است
* به قیمت زندهٔ مرجع (`lastPrice`) fallback می‌کند — هیچ‌جا قیمت سرور بازنویسی نمی‌شود.
*/
const MELTED_GROUP = 'آبشده'
export function isMeltedProduct(product?: Product | null): boolean {
return product?.group_type === MELTED_GROUP
}
/** قیمت پایهٔ خرید (بدون کارمزد) — قیمت سرور، با fallback زندهٔ آبشده وقتی سرور صفر است */
export function getProductBaseBuyPrice(product?: Product | null): number {
if (!product) {
return 0
}
const serverPrice = Number(product.price ?? 0)
if (serverPrice > 0) {
return serverPrice
}
if (isMeltedProduct(product)) {
const liveBuy = getTradeBuyPrice(lastPrice.value)
if (liveBuy > 0) {
const amount = Number(product.amount ?? 0)
return amount > 0 ? liveBuy * amount : liveBuy
}
}
return 0
}
/** قیمت خرید نمایشی محصول (همان مقداری که در لیست و صفحهٔ محصول نشان داده می‌شود) */
export function getProductBuyPrice(product?: Product | null): number {
const base = getProductBaseBuyPrice(product)
if (base <= 0) {
return 0
}
return base + Number(product?.cash_fee_buy ?? 0)
}
/** قیمت پایهٔ فروش (بدون کارمزد) — قیمت فروش سرور، با fallback زندهٔ آبشده */
export function getProductBaseSellPrice(product?: Product | null): number {
if (!product) {
return 0
}
const serverSell = Number(product.sellPrice ?? 0)
if (serverSell > 0) {
return serverSell
}
if (isMeltedProduct(product)) {
const liveSell = getTradeSellPrice(lastPrice.value)
if (liveSell > 0) {
const amount = Number(product.amount ?? 0)
return amount > 0 ? liveSell * amount : liveSell
}
}
return getProductBaseBuyPrice(product)
}
/** قیمت فروش نمایشی محصول */
export function getProductSellPrice(product?: Product | null): number {
const base = getProductBaseSellPrice(product)
if (base <= 0) {
return 0
}
return base - Number(product?.cash_fee_sell ?? 0)
}
/** محصول وقتی قیمت پایهٔ معتبری ندارد، غیرفعال (غیرقابل معامله) است */
export function isProductPriceInactive(product?: Product | null): boolean {
return getProductBaseBuyPrice(product) <= 0
}
/**
* نمایش محصول در فروشگاه — اولویت با `show` سرور.
* فقط برای آبشده‌ای که قیمت سرورش هنوز نرسیده، با قیمت زنده تکمیل می‌کنیم.
*/
export function isProductVisible(product?: Product | null): boolean {
if (!product || product.active === false) {
return false
}
if (product.show) {
return true
}
// سازگاری: آبشده‌ای که سرور هنوز قیمت نداده ولی قیمت زنده موجود است
return isMeltedProduct(product) && getProductBaseBuyPrice(product) > 0
}
+10
View File
@@ -3,10 +3,15 @@ import type { Product } from '@/models/products'
type ProductListHandler = (products: Product[]) => void
const handlers = new Set<ProductListHandler>()
let lastProductsList: Product[] | null = null
export function subscribeProductListStream(handler: ProductListHandler) {
handlers.add(handler)
if (lastProductsList?.length) {
handler(lastProductsList)
}
return () => {
handlers.delete(handler)
}
@@ -17,5 +22,10 @@ export function applyProductsListFromStream(products: Product[]) {
return
}
lastProductsList = products
handlers.forEach((handler) => handler(products))
}
export function getLastStreamProductsList(): Product[] | null {
return lastProductsList
}
+46 -1
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { useSettingsStore } from '@/store/settingsStore'
import { lastPrice } from '@/utils/price'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
export interface StoreTradingHours {
start: string
@@ -162,3 +162,48 @@ export function getStoreClosedMessage(): string {
return `فروشگاه در حال حاضر بسته است. ساعت کاری از ${open} تا ${close} می‌باشد.`
}
export type PriceDisplayState = 'open' | 'closed' | 'no_quote'
export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
/**
* وضعیت نمایش قیمت در صفحه اصلی:
* - closed → فروشگاه بسته است (خارج از ساعت کاری close_price یا بازه محصول)
* - no_quote → مظنه در دسترس نیست (قیمت زنده نداریم و show_last_price خاموش است)
* - open → نمایش عادی قیمت
*
* اولویت با status ارسالی از سرور است؛ اگر نبود به منطق سمت کلاینت برمی‌گردیم.
*/
export function getPriceDisplayState(): PriceDisplayState {
void productTradingHoursVersion.value
const p = lastPrice.value
const status = p?.status
if (status === 'closed') {
return 'closed'
}
if (status === 'no_quote') {
return getTradeBuyPrice(p) > 0 ? 'open' : 'no_quote'
}
if (status === 'open') {
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0 ? 'open' : 'no_quote'
}
// سرور status نفرستاده (سازگاری با نسخه قدیمی) → استنتاج سمت کلاینت
if (isStoreClosed()) {
return 'closed'
}
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0 ? 'open' : 'no_quote'
}
export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {
if (state === 'closed') {
return getStoreClosedMessage()
}
if (state === 'no_quote') {
return NO_QUOTE_MESSAGE
}
return ''
}
+75 -43
View File
@@ -55,61 +55,93 @@ _axios.interceptors.response.use(
return response.data
},
async function (error) {
if (error.response.data && error.response.data.message) {
const response = error.response
const config = error.config as MyAxiosRequestConfig | undefined
checkStatus(error.response.data.message, error.response.status, error.config)
}
//console.log('this is error on interceptors.response', error.config.meta)
/* if (error.response && (error.response.status == 401 || error.response.status == 400)) {
//debugger
removeToken()
router.push({ name: 'login' })
}*/
if (error.response && error.response.status == 401) {
///removeToken()
console.log('error')
if (response?.status === 401) {
removeToken()
// Don't redirect to login if user is on buyProduct page
if (router.currentRoute.value.name !== 'buyProduct') {
await router.push({name: 'login'})
await router.push({name: 'login'})
}
} else if (error.response && error.response.status == 403) {
message.error('شما دسترسی لازم برای انجام این عملیات را ندارید')
} else if (error.response && error.response.status == 422) {
// message.error(error.response.data?.message)
} else if (error.response) {
message.error('خطا در بارگذاری اطلاعات')
return Promise.reject(error)
}
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
//const { response, code, message, config } = error || {}
//const errorMessageMode = config?.requestOptions?.errorMessageMode || 'none'
//const msg: string = response?.data?.error?.message ?? ''
//checkStatus(error?.response?.status, msg)
if (!shouldShowApiError(config)) {
return Promise.reject(error)
}
const apiMessage = extractApiErrorMessage(response?.data)
if (response?.status === 403) {
showApiError(apiMessage || 'شما دسترسی لازم برای انجام این عملیات را ندارید', config)
return Promise.reject(error)
}
if (response?.status === 422) {
if (apiMessage) {
showApiError(apiMessage, config)
}
return Promise.reject(error)
}
if (response) {
showApiError(apiMessage || DEFAULT_LOAD_ERROR, config)
return Promise.reject(error)
}
showApiError(DEFAULT_LOAD_ERROR, config)
return Promise.reject(error)
})
function checkStatus(msg: string, code: number, config: any) {
//const { code, data, message } = data
const errorMessage = get(config, 'meta.errorMessage', 'message')
/*if (config.meta) {
if (config.meta.message) {
errorMessage = config.meta.message
}
}*/
// console.log('this is config', config)
if (errorMessage == 'none') return
switch (errorMessage) {
case 'message':
message.error(msg)
break
default:
message.error(msg)
const DEFAULT_LOAD_ERROR = 'خطا در بارگذاری اطلاعات'
function extractApiErrorMessage(data: unknown): string {
if (!data || typeof data !== 'object') {
return ''
}
const record = data as Record<string, unknown>
const rawMessage = record.message
if (typeof rawMessage === 'string' && rawMessage.trim()) {
return rawMessage.trim()
}
if (typeof rawMessage === 'number') {
return String(rawMessage)
}
const rawError = record.error
if (typeof rawError === 'string' && rawError.trim()) {
return rawError.trim()
}
const errors = record.errors
if (errors && typeof errors === 'object') {
for (const value of Object.values(errors as Record<string, unknown>)) {
if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim()) {
return value[0].trim()
}
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
}
return ''
}
function shouldShowApiError(config: MyAxiosRequestConfig | undefined): boolean {
return get(config, 'meta.errorMessage', 'message') !== 'none'
}
function showApiError(msg: string, config: MyAxiosRequestConfig | undefined) {
if (!shouldShowApiError(config)) {
return
}
message.error(msg)
}
const myAxios = {
//AxiosResponse<T>