diff --git a/.cursor/skills/abshode-domain-lookup/learned-notes.md b/.cursor/skills/abshode-domain-lookup/learned-notes.md
index 92a437a..42a8fbf 100644
--- a/.cursor/skills/abshode-domain-lookup/learned-notes.md
+++ b/.cursor/skills/abshode-domain-lookup/learned-notes.md
@@ -66,3 +66,6 @@
- 2026-11-03 | ویرایش/حذف تبدیل در انتظار | همان تاریخچه + `CopperConvertRequestService::update/remove` | فقط status=pending؛ ویرایش فرم را با همان حلقهها پر میکند و PUT میزند
- 2026-11-03 | چیدمان دسکتاپ تبدیل | `convert/desktop/DesktopConvertView.vue` | مثل کیف پول: `dwallet-grid`؛ فرم راست، تاریخچه چپ با `variant=side`
- 2026-11-03 | مداد ویرایش تبدیل | `convert/components/CopperConvertHistory.vue` | فقط pending؛ آیکون `vuesax-linear:edit` کنار وضعیت، همان `beginEdit`
+- 2026-11-03 | چهار قیمت هیرو خانه | `home/desktop/components/DesktopLivePrice.vue` + سرور `ZaarboxPriceService::copperDollarReferences` | مس جهانی=کلید `basemetal_london_copper`، دلار آزاد=`tgju_usd`؛ تن=ضرب، کیلو=تقسیم بر ۱۰۰۰
+- 2026-11-03 | چهار قیمت موبایل خانه | `home/components/CurrentPrice.vue` + `composables/useHomeQuoteRows.ts` | همان چهار ردیف دسکتاپ؛ منطق مشترک در useHomeQuoteRows
+- 2026-11-03 | هاور نمودار دارایی | `assets/index.vue` + `useAssetsPortfolioChart.ts` + `chartAreaPaths.ts` | نزدیکترین نقطهٔ رسمشده؛ مقدار همان نمونهٔ دارایی، نه قیمت مس
diff --git a/src/models/home.ts b/src/models/home.ts
index 65eba6a..231d2a8 100644
--- a/src/models/home.ts
+++ b/src/models/home.ts
@@ -35,4 +35,8 @@ export interface Price {
/** montzar | zaarbox | main_product | store = قیمت زنده، database_last = آخرین قیمت ذخیرهشده */
price_source?: string | null
last_historical?: number
+ copper_world?: number
+ dollar_free?: number
+ copper_ton?: string
+ copper_kilo?: string
}
\ No newline at end of file
diff --git a/src/utils/chartAreaPaths.ts b/src/utils/chartAreaPaths.ts
index ebfd027..2448885 100644
--- a/src/utils/chartAreaPaths.ts
+++ b/src/utils/chartAreaPaths.ts
@@ -3,6 +3,13 @@ export type ChartSeriesPoint = {
value: number
}
+export type ChartPlotSample = {
+ x: number
+ y: number
+ value: number
+ timeMs: number
+}
+
const CHART_PAD_Y = 14
const DEFAULT_MAX_POINTS = 22
@@ -67,16 +74,17 @@ export function buildSeriesChartPaths(
linePath: '',
areaPath: '',
endDot: null as { x: number; y: number } | null,
+ samples: [] as ChartPlotSample[],
+ width,
+ height,
}
if (points.length < 2) return empty
const maxPoints = options?.maxPoints ?? DEFAULT_MAX_POINTS
const useSmooth = options?.smooth ?? true
- let series = downsampleSeries(points, maxPoints)
- if (useSmooth) {
- series = smoothSeries(series, 3)
- }
+ const sampled = downsampleSeries(points, maxPoints)
+ const series = useSmooth ? smoothSeries(sampled, 3) : sampled
const values = series.map((point) => point.value)
let min = Math.min(...values)
@@ -97,11 +105,20 @@ export function buildSeriesChartPaths(
const linePath = buildSmoothLinePath(plot)
const areaPath = `${linePath} L${width} ${height} L0 ${height} Z`
const last = plot[plot.length - 1]
+ const samples = plot.map((point, index) => ({
+ x: point.x,
+ y: point.y,
+ value: sampled[index]?.value ?? series[index].value,
+ timeMs: sampled[index]?.timeMs ?? series[index].timeMs,
+ }))
return {
hasData: true,
linePath,
areaPath,
endDot: last ? { x: last.x, y: last.y } : null,
+ samples,
+ width,
+ height,
}
}
diff --git a/src/views/base/assets/composables/useAssetsPortfolioChart.ts b/src/views/base/assets/composables/useAssetsPortfolioChart.ts
index 32b58a8..789b1ef 100644
--- a/src/views/base/assets/composables/useAssetsPortfolioChart.ts
+++ b/src/views/base/assets/composables/useAssetsPortfolioChart.ts
@@ -4,6 +4,7 @@ import {
type LandingHistoryRange,
} from '@/api/zaarboxPriceHistory'
import { buildSeriesChartPaths } from '@/utils/chartAreaPaths'
+import { usePrice } from '@/utils/usePrice'
const CHART_RANGE_OPTIONS = [
{ value: 'DAILY' as const, shortLabel: '۲۴ ساعت', label: 'ارزش دارایی — ۲۴ ساعت گذشته' },
{ value: 'WEEKLY' as const, shortLabel: 'هفته', label: 'ارزش دارایی — هفته گذشته' },
@@ -32,6 +33,7 @@ export function useAssetsPortfolioChart(
}
function setChartRange(range: LandingHistoryRange) {
+ chartHover.value = null
chartRange.value = range
void loadChartHistory(range)
}
@@ -61,6 +63,53 @@ export function useAssetsPortfolioChart(
const portfolioChart = computed(() => buildSeriesChartPaths(portfolioSeries.value))
+ const chartHover = ref<{
+ xPercent: number
+ yPercent: number
+ valueLabel: string
+ timeLabel: string
+ } | null>(null)
+
+ function formatChartTimeLabel(timeMs: number) {
+ try {
+ return new Intl.DateTimeFormat('fa-IR', {
+ timeZone: 'Asia/Tehran',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(new Date(timeMs))
+ } catch {
+ return ''
+ }
+ }
+
+ function onChartPointerMove(event: PointerEvent) {
+ const chart = portfolioChart.value
+ if (!chart.hasData || chart.samples.length < 2) {
+ chartHover.value = null
+ return
+ }
+ const target = event.currentTarget as HTMLElement | null
+ if (!target) return
+ const rect = target.getBoundingClientRect()
+ if (rect.width <= 0 || rect.height <= 0) return
+ const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
+ const index = Math.round(ratio * (chart.samples.length - 1))
+ const sample = chart.samples[index]
+ if (!sample) return
+ chartHover.value = {
+ xPercent: (sample.x / chart.width) * 100,
+ yPercent: (sample.y / chart.height) * 100,
+ valueLabel: usePrice(sample.value),
+ timeLabel: formatChartTimeLabel(sample.timeMs),
+ }
+ }
+
+ function onChartPointerLeave() {
+ chartHover.value = null
+ }
+
watch(
() => cashRial.value + metalRial.value,
(total) => {
@@ -81,6 +130,9 @@ export function useAssetsPortfolioChart(
chartDrawn,
chartLoading,
portfolioChart,
+ chartHover,
+ onChartPointerMove,
+ onChartPointerLeave,
setChartRange,
initAssetsPortfolioChart,
}
diff --git a/src/views/base/assets/index.vue b/src/views/base/assets/index.vue
index 1cea440..6be79c1 100644
--- a/src/views/base/assets/index.vue
+++ b/src/views/base/assets/index.vue
@@ -18,6 +18,8 @@
نمودار ارزش دارایی در دسترس نیست
+
+
+
+
+ {{ chartHover.valueLabel }}
+ {{ chartHover.timeLabel }}
+
+
@@ -62,7 +46,7 @@
import { computed } from 'vue'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
-import { getTradeBuyPrice, lastPrice, percentPriceChanged, price } from '@/utils/price'
+import { useHomeQuoteRows } from '@/views/base/home/composables/useHomeQuoteRows'
import {
NO_QUOTE_MESSAGE,
STORE_CLOSED_TITLE,
@@ -70,7 +54,6 @@ import {
getStoreClosedHint,
isHomeQuotePending,
} from '@/utils/storeHours'
-import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
const userStore = useUserStore()
@@ -98,17 +81,7 @@ const containerClass = computed(() => {
return 'boursecu-price-box boursecu-price-box--open'
})
-const percentChange = computed(() => {
- if (!getTradeBuyPrice(lastPrice.value)) return 0
- return percentPriceChanged()
-})
-
-const displayGoldVal = computed(() => {
- if (displayState.value !== 'open') return 0
- return getTradeBuyPrice(lastPrice.value)
-})
-
-void price
+const { quoteRows } = useHomeQuoteRows()
diff --git a/src/views/base/home/composables/useHomeQuoteRows.ts b/src/views/base/home/composables/useHomeQuoteRows.ts
new file mode 100644
index 0000000..eefa58c
--- /dev/null
+++ b/src/views/base/home/composables/useHomeQuoteRows.ts
@@ -0,0 +1,119 @@
+import { computed, ref, watch } from 'vue'
+import { lastPrice } from '@/utils/price'
+import { usePriceWithoutType } from '@/utils/usePrice'
+
+const BOARD_FEED_URL = 'https://price.zaarbox.ir/api/free'
+
+type BoardRefs = { copper: number; dollar: number }
+
+const boardCache = ((globalThis as typeof globalThis & {
+ __boursecuBoardRefs?: { started: boolean; refs: BoardRefs | null }
+}).__boursecuBoardRefs ??= { started: false, refs: null })
+
+function boardRowTotal(row: Record): number {
+ const price = Math.round(Number(row.price) || 0)
+ if (price <= 0) return 0
+ return price + Math.round(Number(row.profit) || 0)
+}
+
+function worldCopperScore(row: Record): number | null {
+ const key = String(row.key ?? '').trim().toLowerCase()
+ const name = String(row.name ?? '').trim()
+ if (key.includes('basemetal_london_copper')) return 0
+ if (name === 'TGJU فلز مس') return 1
+ if (name.includes('مس جهانی')) return 2
+ return null
+}
+
+function freeDollarScore(row: Record): number | null {
+ const key = String(row.key ?? '').trim().toLowerCase()
+ const name = String(row.name ?? '').trim()
+ if (key.includes('copper') || name.includes('کاتد') || name.includes('مس')) return null
+ if (key === 'tgju_usd') return 0
+ if (name === 'TGJU دلار' || name === 'tgju دلار آمریکا') return 1
+ if (name.includes('دلار') && name.includes('آزاد')) return 2
+ return null
+}
+
+function pickBoardRefs(rows: unknown[]) {
+ let copper = 0
+ let dollar = 0
+ let copperScore = Number.POSITIVE_INFINITY
+ let dollarScore = Number.POSITIVE_INFINITY
+ for (const item of rows) {
+ if (!item || typeof item !== 'object') continue
+ const row = item as Record
+ const total = boardRowTotal(row)
+ if (total <= 0) continue
+ const copperRank = worldCopperScore(row)
+ if (copperRank !== null && copperRank < copperScore) {
+ copperScore = copperRank
+ copper = total
+ }
+ const dollarRank = freeDollarScore(row)
+ if (dollarRank !== null && dollarRank < dollarScore) {
+ dollarScore = dollarRank
+ dollar = total
+ }
+ }
+ return copper > 0 && dollar > 0 ? { copper, dollar } : null
+}
+
+const sharedBoardRefs = ref(boardCache.refs)
+
+export function useHomeQuoteRows() {
+ function ensureBoardRefs() {
+ const liveCopper = Number(lastPrice.value?.copper_world ?? 0)
+ const liveDollar = Number(lastPrice.value?.dollar_free ?? 0)
+ if (liveCopper > 0 && liveDollar > 0) return
+ if (boardCache.refs) {
+ sharedBoardRefs.value = boardCache.refs
+ return
+ }
+ if (boardCache.started) return
+ boardCache.started = true
+ void fetch(BOARD_FEED_URL)
+ .then((res) => res.json())
+ .then((rows: unknown) => {
+ if (!Array.isArray(rows)) return
+ const picked = pickBoardRefs(rows)
+ if (!picked) return
+ boardCache.refs = picked
+ sharedBoardRefs.value = picked
+ })
+ .catch(() => {
+ boardCache.started = false
+ })
+ }
+
+ watch(lastPrice, () => ensureBoardRefs(), { immediate: true })
+
+ const quoteRows = computed(() => {
+ const fromServer = Number(lastPrice.value?.copper_world ?? 0) > 0
+ && Number(lastPrice.value?.dollar_free ?? 0) > 0
+ const copper = fromServer
+ ? Number(lastPrice.value?.copper_world)
+ : (sharedBoardRefs.value?.copper ?? 0)
+ const dollar = fromServer
+ ? Number(lastPrice.value?.dollar_free)
+ : (sharedBoardRefs.value?.dollar ?? 0)
+ const tonText = String(lastPrice.value?.copper_ton ?? '')
+ const kiloText = String(lastPrice.value?.copper_kilo ?? '')
+ const ton = fromServer && tonText !== '' && tonText !== '0'
+ ? Number(tonText)
+ : (copper > 0 && dollar > 0 ? copper * dollar : 0)
+ const kilo = fromServer && kiloText !== '' && kiloText !== '0'
+ ? Number(kiloText)
+ : (ton > 0 ? ton / 1000 : 0)
+ const text = (value: number) => (value > 0 ? usePriceWithoutType(value) : '—')
+
+ return [
+ { label: 'قیمت مس جهانی', value: text(copper), unit: copper > 0 ? 'دلار' : '' },
+ { label: 'قیمت دلار بازار آزاد', value: text(dollar), unit: dollar > 0 ? 'ریال' : '' },
+ { label: 'قیمت تن', value: text(ton), unit: ton > 0 ? 'ریال' : '' },
+ { label: 'قیمت کیلوگرم', value: text(kilo), unit: kilo > 0 ? 'ریال' : '' },
+ ]
+ })
+
+ return { quoteRows }
+}
diff --git a/src/views/base/home/desktop/components/DesktopLivePrice.vue b/src/views/base/home/desktop/components/DesktopLivePrice.vue
index 9a34968..845bf8f 100644
--- a/src/views/base/home/desktop/components/DesktopLivePrice.vue
+++ b/src/views/base/home/desktop/components/DesktopLivePrice.vue
@@ -32,27 +32,15 @@
-
- قیمت لحظهای کاتد مس
-
-
-
-
- {{ formatSignedPercentChange(percentChange) }}
-
- {{ usePriceWithoutType(displayGoldVal) }}
- ریال
-
+
+ -
+ {{ row.label }}
+
+ {{ row.value }}
+ {{ row.unit }}
+
+
+
@@ -61,7 +49,7 @@
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
-import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price'
+import { useHomeQuoteRows } from '@/views/base/home/composables/useHomeQuoteRows'
import {
NO_QUOTE_MESSAGE,
STORE_CLOSED_TITLE,
@@ -69,7 +57,6 @@ import {
getStoreClosedHint,
isHomeQuotePending,
} from '@/utils/storeHours'
-import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
const userStore = useUserStore()
@@ -87,15 +74,7 @@ const statusHint = computed(() => {
return 'در حال حاضر قیمتی برای نمایش وجود ندارد.'
})
-const displayGoldVal = computed(() => {
- if (displayState.value !== 'open') return 0
- return getTradeBuyPrice(lastPrice.value)
-})
-
-const percentChange = computed(() => {
- if (!getTradeBuyPrice(lastPrice.value)) return 0
- return percentPriceChanged()
-})
+const { quoteRows } = useHomeQuoteRows()