feat(quotes): show world copper, dollar, ton, and kilo on home

Add assets chart hover so the portfolio line shows the value under the pointer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 19:57:39 +03:30
co-authored by Cursor
parent c21738950d
commit 5596fc75d0
8 changed files with 380 additions and 109 deletions
@@ -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` | نزدیک‌ترین نقطهٔ رسم‌شده؛ مقدار همان نمونهٔ دارایی، نه قیمت مس
+4
View File
@@ -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
}
+21 -4
View File
@@ -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,
}
}
@@ -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,
}
+92 -2
View File
@@ -18,6 +18,8 @@
<div
class="assets-chart__plot"
:class="{ 'is-drawn': chartDrawn }"
@pointermove="onChartPointerMove"
@pointerleave="onChartPointerLeave"
>
<svg
v-if="portfolioChart.hasData"
@@ -54,6 +56,30 @@
</g>
</svg>
<p v-else class="assets-chart__empty">نمودار ارزش دارایی در دسترس نیست</p>
<template v-if="chartHover">
<div
class="assets-chart__crosshair"
:style="{ left: `${chartHover.xPercent}%` }"
/>
<div
class="assets-chart__dot"
:style="{
left: `${chartHover.xPercent}%`,
top: `${chartHover.yPercent}%`,
}"
/>
<div
class="assets-chart__tip"
:class="{
'is-near-end': chartHover.xPercent > 68,
'is-near-start': chartHover.xPercent < 22,
}"
:style="{ left: `${chartHover.xPercent}%` }"
>
<strong>{{ chartHover.valueLabel }}</strong>
<span v-if="chartHover.timeLabel">{{ chartHover.timeLabel }}</span>
</div>
</template>
</div>
<div class="assets-chart__ranges">
<button
@@ -216,8 +242,11 @@ const {
chartRange,
chartRangeOptions,
chartDrawn,
portfolioChart,
setChartRange,
portfolioChart,
chartHover,
onChartPointerMove,
onChartPointerLeave,
setChartRange,
initAssetsPortfolioChart,
} = useAssetsPortfolioChart(cashWallet, metalRialValue)
const portfolioInsight = computed(() => assetsSummary.value?.portfolio)
@@ -453,6 +482,67 @@ onMounted(async () => {
font-size: 0.8rem;
}
.assets-chart__crosshair {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(201, 138, 95, 0.45);
pointer-events: none;
transform: translateX(-50%);
z-index: 2;
}
.assets-chart__dot {
position: absolute;
width: 11px;
height: 11px;
border-radius: 50%;
background: #c98a5f;
box-shadow: 0 0 0 5px rgba(201, 138, 95, 0.2);
pointer-events: none;
transform: translate(-50%, -50%);
z-index: 3;
}
.assets-chart__tip {
position: absolute;
top: 8px;
z-index: 4;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-radius: 10px;
background: rgba(26, 43, 71, 0.94);
color: #fff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18);
pointer-events: none;
transform: translateX(-50%);
white-space: nowrap;
&.is-near-end {
transform: translateX(-100%);
}
&.is-near-start {
transform: translateX(0);
}
strong {
font-size: 12px;
font-weight: 700;
direction: ltr;
text-align: right;
}
span {
color: rgba(255, 255, 255, 0.78);
font-size: 10px;
font-weight: 500;
}
}
.assets-chart__ranges {
display: flex;
gap: 0.4rem;
+40 -71
View File
@@ -29,31 +29,15 @@
</template>
<template v-else>
<div class="price-label price-label--boursecu">
قیمت لحظه‌ای کاتد مس
</div>
<div class="w-full items-center flex justify-center">
<div v-if="price" class="flex gap-1 text-xs items-center">
<div
v-if="percentChange !== 0"
class="price-change-badge"
:class="percentChange > 0 ? 'price-change-badge--up' : 'price-change-badge--down'"
>
<Icon
:key="displayGoldVal"
:size="15"
:icon="percentChange > 0 ? 'vuesax-linear:send' : 'vuesax-linear:received'"
:color="percentChange > 0 ? 'green-600' : 'red-600'"
/>
<span class="price-change-badge__value">{{ formatSignedPercentChange(percentChange) }}</span>
</div>
<span
class="o-text !text-xl font-bold price-odometer--boursecu"
style="direction: ltr"
>{{ usePriceWithoutType(displayGoldVal) }}</span>
<span class="price-unit--boursecu">ریال</span>
</div>
</div>
<ul class="hero-quotes">
<li v-for="row in quoteRows" :key="row.label" class="hero-quotes__row">
<span class="hero-quotes__label">{{ row.label }}</span>
<span class="hero-quotes__value" dir="ltr">
<bdi>{{ row.value }}</bdi>
<small v-if="row.unit">{{ row.unit }}</small>
</span>
</li>
</ul>
</template>
</div>
</template>
@@ -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()
</script>
<style scoped>
@@ -128,20 +101,42 @@ void price
min-height: 88px;
}
.price-label--boursecu {
.hero-quotes {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0 12px;
list-style: none;
}
.hero-quotes__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.hero-quotes__label {
color: rgba(255, 255, 255, 0.92);
font-size: 12px;
font-weight: 600;
width: 100%;
text-align: center;
min-width: 0;
}
.price-odometer--boursecu {
color: #ffffff !important;
.hero-quotes__value {
color: #fff;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.price-unit--boursecu {
color: rgba(255, 255, 255, 0.85);
.hero-quotes__value small {
margin-left: 4px;
color: rgba(255, 255, 255, 0.8);
font-size: 11px;
font-weight: 500;
}
@@ -191,30 +186,4 @@ void price
.status-box--boursecu .status-box__hint {
color: rgba(255, 255, 255, 0.78);
}
.price-change-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.65rem;
border-radius: 0.5rem;
font-weight: 600;
}
.price-change-badge--up {
background: #dcfce7;
color: #16a34a;
}
.price-change-badge--down {
background: #fee2e2;
color: #dc2626;
}
.price-change-badge__value {
direction: ltr;
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
}
</style>
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>
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<BoardRefs | null>(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 }
}
@@ -32,27 +32,15 @@
</template>
<template v-else>
<span class="dhome-label dhome-label--boursecu">
قیمت لحظه‌ای کاتد مس
</span>
<div class="dhome-hero-price__value-row">
<span
v-if="percentChange !== 0"
class="dhome-hero-price__delta"
:class="percentChange > 0 ? 'dhome-hero-price__delta--up' : 'dhome-hero-price__delta--down'"
>
<BasicIcon
:icon="percentChange > 0 ? 'vuesax-linear:arrow-up-2' : 'vuesax-linear:arrow-down-2'"
size="14"
/>
<span class="dhome-hero-price__delta-value">{{ formatSignedPercentChange(percentChange) }}</span>
</span>
<span
class="dhome-hero-price__odometer o-text dhome-hero-price__odometer--boursecu"
style="direction: ltr"
>{{ usePriceWithoutType(displayGoldVal) }}</span>
<span class="dhome-hero-price__unit dhome-hero-price__unit--boursecu">ریال</span>
</div>
<ul class="hero-quotes">
<li v-for="row in quoteRows" :key="row.label" class="hero-quotes__row">
<span class="hero-quotes__label">{{ row.label }}</span>
<span class="hero-quotes__value" dir="ltr">
<bdi>{{ row.value }}</bdi>
<small v-if="row.unit">{{ row.unit }}</small>
</span>
</li>
</ul>
</template>
</div>
</template>
@@ -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()
</script>
<style scoped lang="less">
@@ -127,6 +106,44 @@ const percentChange = computed(() => {
background: rgba(255, 255, 255, 0.25);
}
.hero-quotes {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.hero-quotes__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.hero-quotes__label {
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-weight: 600;
}
.hero-quotes__value {
color: #fff;
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.hero-quotes__value small {
margin-left: 4px;
color: rgba(255, 255, 255, 0.8);
font-size: 11px;
font-weight: 500;
}
.dhome-hero-price__closed {
display: flex;
flex-direction: column;