diff --git a/components.d.ts b/components.d.ts index 1d0c2fb..5a45e11 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/src/App.vue b/src/App.vue index 762eb09..101c762 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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); + } +} diff --git a/src/assets/css/main.less b/src/assets/css/main.less index 57fd767..b2b44b3 100644 --- a/src/assets/css/main.less +++ b/src/assets/css/main.less @@ -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'; diff --git a/src/components/GoldLoading.vue b/src/components/GoldLoading.vue index a9fe61b..68d8c6c 100644 --- a/src/components/GoldLoading.vue +++ b/src/components/GoldLoading.vue @@ -1,9 +1,12 @@ - - diff --git a/src/components/SupportContactBar.vue b/src/components/SupportContactBar.vue index d03ef8c..31b0536 100644 --- a/src/components/SupportContactBar.vue +++ b/src/components/SupportContactBar.vue @@ -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; diff --git a/src/components/TradeFeeConfirmSummary.vue b/src/components/TradeFeeConfirmSummary.vue new file mode 100644 index 0000000..da43f9e --- /dev/null +++ b/src/components/TradeFeeConfirmSummary.vue @@ -0,0 +1,102 @@ + + + diff --git a/src/composables/useTrackingSystemsFeature.ts b/src/composables/useTrackingSystemsFeature.ts new file mode 100644 index 0000000..76d2783 --- /dev/null +++ b/src/composables/useTrackingSystemsFeature.ts @@ -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, + } +} diff --git a/src/layouts/config/navigation.ts b/src/layouts/config/navigation.ts index 51bc417..136e04d 100644 --- a/src/layouts/config/navigation.ts +++ b/src/layouts/config/navigation.ts @@ -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 } diff --git a/src/layouts/desktop/DesktopSidebar.vue b/src/layouts/desktop/DesktopSidebar.vue index 5ab1232..ec77a14 100644 --- a/src/layouts/desktop/DesktopSidebar.vue +++ b/src/layouts/desktop/DesktopSidebar.vue @@ -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() diff --git a/src/layouts/mobile/MobileOverflowMenu.vue b/src/layouts/mobile/MobileOverflowMenu.vue index c0951a0..894751b 100644 --- a/src/layouts/mobile/MobileOverflowMenu.vue +++ b/src/layouts/mobile/MobileOverflowMenu.vue @@ -87,6 +87,17 @@
درباره ما
+ +
+ +
سایت‌های ری‌گیری
+
+
() const authenticated = computed(() => isToken()) +const { trackingSystemsEnabled } = useTrackingSystemsFeature() function go(routeName: string) { emit('navigate', routeName) diff --git a/src/models/products.ts b/src/models/products.ts index 01bf583..06db4d0 100644 --- a/src/models/products.ts +++ b/src/models/products.ts @@ -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 diff --git a/src/router/index.ts b/src/router/index.ts index a34ff80..5ab2f84 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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', diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index e069a80..70d6e63 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -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, } }) diff --git a/src/utils/metalLabels.ts b/src/utils/metalLabels.ts new file mode 100644 index 0000000..7d03def --- /dev/null +++ b/src/utils/metalLabels.ts @@ -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 : 'آبشده' +} diff --git a/src/utils/productAssets.ts b/src/utils/productAssets.ts index 5151b32..28f7a52 100644 --- a/src/utils/productAssets.ts +++ b/src/utils/productAssets.ts @@ -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 | 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) +} diff --git a/src/utils/productDealMethod.ts b/src/utils/productDealMethod.ts new file mode 100644 index 0000000..8b73cea --- /dev/null +++ b/src/utils/productDealMethod.ts @@ -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} ریال` +} diff --git a/src/utils/productOrderLimit.ts b/src/utils/productOrderLimit.ts new file mode 100644 index 0000000..8c9b8a0 --- /dev/null +++ b/src/utils/productOrderLimit.ts @@ -0,0 +1,120 @@ +import type { Product } from '@/models/products' +import { usePriceWithoutType } from '@/utils/usePrice' +import { persianToEnglishDigits } from '@/utils/persianDigits' + +type LimitProduct = Pick | 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 +} diff --git a/src/utils/productTradeOpen.ts b/src/utils/productTradeOpen.ts new file mode 100644 index 0000000..ef599ef --- /dev/null +++ b/src/utils/productTradeOpen.ts @@ -0,0 +1,45 @@ +import type { Product } from '@/models/products' + +type TradeOpenProduct = Pick | 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 + ? 'خرید این محصول در این لحظه مقدور نمی‌باشد' + : 'فروش این محصول در این لحظه مقدور نمی‌باشد' +} diff --git a/src/utils/serviceFee.ts b/src/utils/serviceFee.ts index b95de92..7c501fd 100644 --- a/src/utils/serviceFee.ts +++ b/src/utils/serviceFee.ts @@ -3,28 +3,63 @@ import { getSettings } from '@/api/configuration' export const serviceFeePercentage = ref(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, + } +} diff --git a/src/utils/trackingSystemsSettings.ts b/src/utils/trackingSystemsSettings.ts new file mode 100644 index 0000000..caa92c2 --- /dev/null +++ b/src/utils/trackingSystemsSettings.ts @@ -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 | 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 + 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 + 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 { + 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 +} diff --git a/src/utils/weightUnit.ts b/src/utils/weightUnit.ts new file mode 100644 index 0000000..3309b20 --- /dev/null +++ b/src/utils/weightUnit.ts @@ -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}` +} diff --git a/src/utils/zIndex.ts b/src/utils/zIndex.ts new file mode 100644 index 0000000..dfcea2c --- /dev/null +++ b/src/utils/zIndex.ts @@ -0,0 +1,2 @@ +/** Overlay dialogs (loading / blocking) */ +export const APP_Z_OVERLAY = 5600 diff --git a/src/views/base/fund/info/components/Item.vue b/src/views/base/fund/info/components/Item.vue index d7d4f28..8ed0f81 100644 --- a/src/views/base/fund/info/components/Item.vue +++ b/src/views/base/fund/info/components/Item.vue @@ -33,57 +33,41 @@ - +
{{ props.item.title }}
-
-
مقدار {{ props.title }} مبادله شده:
-
-
{{ formatGoldAmount(item.gold) }}
- {{ item.unit }} -
-
-
-
موجودی {{ props.title }}:
-
-
{{ formatGoldAmount(item.balance_gold) }}
- {{ item.unit }} -
-
-
-
وضعیت:
-
- - {{ item.active ? 'موفق' : 'نا موفق' }} - -
-
-
-
تاریخ:
-
{{ toJalaliNotime(item.expire_at) }}
-
-
-
زمان:
-
{{ toTime(item.expire_at) }}
-
+ بازگشت
+ + + + diff --git a/src/views/base/gold/desktop/styles/desktop-gold.less b/src/views/base/gold/desktop/styles/desktop-gold.less new file mode 100644 index 0000000..d53b767 --- /dev/null +++ b/src/views/base/gold/desktop/styles/desktop-gold.less @@ -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; + } +} diff --git a/src/views/base/gold/index.vue b/src/views/base/gold/index.vue index 3cdd78a..ee8a1f1 100644 --- a/src/views/base/gold/index.vue +++ b/src/views/base/gold/index.vue @@ -1,31 +1,54 @@ diff --git a/src/views/base/recoverySites/composables/useRecoverySitesPage.ts b/src/views/base/recoverySites/composables/useRecoverySitesPage.ts new file mode 100644 index 0000000..634a598 --- /dev/null +++ b/src/views/base/recoverySites/composables/useRecoverySitesPage.ts @@ -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 + loading: Ref + isEmpty: ComputedRef + openSite: (url: string) => void +} + +export const RECOVERY_SITES_PAGE_CONTEXT_KEY: InjectionKey = + 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([]) + 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 +} diff --git a/src/views/base/recoverySites/desktop/DesktopRecoverySitesView.vue b/src/views/base/recoverySites/desktop/DesktopRecoverySitesView.vue new file mode 100644 index 0000000..1d5b37f --- /dev/null +++ b/src/views/base/recoverySites/desktop/DesktopRecoverySitesView.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/views/base/recoverySites/index.vue b/src/views/base/recoverySites/index.vue new file mode 100644 index 0000000..13890d1 --- /dev/null +++ b/src/views/base/recoverySites/index.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/src/views/base/recoverySites/mobile/MobileRecoverySitesView.vue b/src/views/base/recoverySites/mobile/MobileRecoverySitesView.vue new file mode 100644 index 0000000..149e618 --- /dev/null +++ b/src/views/base/recoverySites/mobile/MobileRecoverySitesView.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/views/base/user/composables/useUserPage.ts b/src/views/base/user/composables/useUserPage.ts index 5ac0cc8..aee0976 100644 --- a/src/views/base/user/composables/useUserPage.ts +++ b/src/views/base/user/composables/useUserPage.ts @@ -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(() => [ - { - 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(() => + [ + { + 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) { diff --git a/src/views/base/user/mobile/MobileUserView.vue b/src/views/base/user/mobile/MobileUserView.vue index ae0b91f..1d66810 100644 --- a/src/views/base/user/mobile/MobileUserView.vue +++ b/src/views/base/user/mobile/MobileUserView.vue @@ -108,6 +108,19 @@
درباره ما
+ +
+ +
سایت‌های ری‌گیری
+
+
@@ -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() const logoutLoading = ref(false) const { y } = useWindowScroll() +const { trackingSystemsEnabled } = useTrackingSystemsFeature() const { editPassVisible, editPasswordVisible, copyCode, shareCode } = useUserPageContext()