feat(home): landing-style live chart, dynamic product tabs, and price copy
Replace home chart with landing live card, catalog-driven tabs, and smoother range transitions; normalize مظنه to قیمت in user-facing strings; add installment and zaarbox chart APIs.
This commit is contained in:
@@ -85,7 +85,7 @@ const statusHint = computed(() => {
|
||||
if (displayState.value === 'closed') {
|
||||
return getStoreClosedHint(userStore.getValueByKey('closed_message'))
|
||||
}
|
||||
return 'در حال حاضر مظنهای برای نمایش وجود ندارد.'
|
||||
return 'در حال حاضر قیمتی برای نمایش وجود ندارد.'
|
||||
})
|
||||
|
||||
const containerClass = computed(() => {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="dlanding-live dlanding-live--home-embed">
|
||||
<article class="dlanding-live__main">
|
||||
<div v-if="chartProductTabs.length" class="dlanding-live__head">
|
||||
<div class="dlanding-live__product-tabs" role="tablist" aria-label="انتخاب محصول">
|
||||
<button
|
||||
v-for="product in chartProductTabs"
|
||||
:key="product.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeProductId === product.id"
|
||||
:class="{ 'is-active': activeProductId === product.id }"
|
||||
@click="setLiveProduct(product.id)"
|
||||
>
|
||||
{{ product.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dlanding-live__price-block">
|
||||
<div class="dlanding-live__price-row">
|
||||
<strong class="dlanding-live__price">{{ formattedLivePrice }}</strong>
|
||||
<span class="dlanding-live__unit">ریال / کیلوگرم</span>
|
||||
</div>
|
||||
<div v-if="dayChangePercent !== 0" class="dlanding-live__change">
|
||||
<span
|
||||
class="dlanding-live__change-badge"
|
||||
:class="dayChangePercent > 0 ? 'is-up' : 'is-down'"
|
||||
>
|
||||
{{ formatSignedPercentChange(dayChangePercent) }}
|
||||
<template v-if="dayChangeAmountLabel"> · {{ dayChangeAmountLabel }}</template>
|
||||
</span>
|
||||
<span class="dlanding-live__change-label">نسبت به ابتدای روز</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dlanding-live__chart-wrap">
|
||||
<div class="dlanding-live__chart-head">
|
||||
<div class="dlanding-live__range-tabs" role="tablist" aria-label="بازه نمودار">
|
||||
<button
|
||||
v-for="option in liveChartRangeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="liveChartRange === option.value"
|
||||
:class="{ 'is-active': liveChartRange === option.value }"
|
||||
@click="setLiveChartRange(option.value)"
|
||||
>
|
||||
{{ option.shortLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="dlanding-live__chart-caption">{{ liveChartRangeLabel }}</span>
|
||||
</div>
|
||||
<div
|
||||
ref="liveChartPlotEl"
|
||||
class="dlanding-live__chart"
|
||||
:class="{ 'is-drawn': liveChartDrawn }"
|
||||
@pointermove="onLiveChartPointerMove"
|
||||
@pointerleave="onLiveChartPointerLeave"
|
||||
>
|
||||
<svg
|
||||
v-if="liveSectionChart.hasData"
|
||||
class="dlanding-live__chart-svg"
|
||||
viewBox="0 0 560 160"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="homeLiveChartFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#c98a5f" stop-opacity="0.3" />
|
||||
<stop offset="100%" stop-color="#c98a5f" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="dlanding-live__chart-area"
|
||||
:d="liveSectionChart.areaPath"
|
||||
fill="url(#homeLiveChartFill)"
|
||||
/>
|
||||
<path
|
||||
class="dlanding-live__chart-path"
|
||||
:d="liveSectionChart.linePath"
|
||||
pathLength="1"
|
||||
fill="none"
|
||||
stroke="#c98a5f"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<g class="dlanding-live__chart-endpoint">
|
||||
<circle :cx="liveSectionChart.lastX" :cy="liveSectionChart.lastY" r="5" fill="#c98a5f" />
|
||||
<circle
|
||||
:cx="liveSectionChart.lastX"
|
||||
:cy="liveSectionChart.lastY"
|
||||
r="10"
|
||||
fill="#c98a5f"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div v-else class="dlanding-live__chart-empty">
|
||||
{{ liveChartLoading ? 'در حال بارگذاری نمودار…' : 'نمودار در دسترس نیست' }}
|
||||
</div>
|
||||
<template v-if="liveChartHover">
|
||||
<div
|
||||
class="dlanding-live__chart-crosshair"
|
||||
:style="{ left: `${liveChartHover.xPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
class="dlanding-live__chart-dot"
|
||||
:style="{
|
||||
left: `${liveChartHover.xPercent}%`,
|
||||
top: `${liveChartHover.yPercent}%`,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="dlanding-live__chart-tooltip"
|
||||
:class="{ 'is-near-end': liveChartHover.xPercent > 72 }"
|
||||
:style="{ left: `${liveChartHover.xPercent}%` }"
|
||||
>
|
||||
<strong>{{ liveChartHover.priceLabel }}</strong>
|
||||
<span>{{ liveChartHover.timeLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import {
|
||||
bindLiveChartDraw,
|
||||
initHomeLandingLiveChart,
|
||||
useLandingLiveChart,
|
||||
} from '../composables/useLandingLiveChart'
|
||||
|
||||
const {
|
||||
activeProductId,
|
||||
chartProductTabs,
|
||||
liveChartRange,
|
||||
liveChartRangeOptions,
|
||||
liveChartLoading,
|
||||
liveChartHover,
|
||||
liveChartDrawn,
|
||||
liveChartPlotEl,
|
||||
liveSectionChart,
|
||||
formattedLivePrice,
|
||||
liveChartRangeLabel,
|
||||
dayChangePercent,
|
||||
dayChangeAmountLabel,
|
||||
formatSignedPercentChange,
|
||||
setLiveProduct,
|
||||
setLiveChartRange,
|
||||
onLiveChartPointerMove,
|
||||
onLiveChartPointerLeave,
|
||||
} = useLandingLiveChart()
|
||||
|
||||
onMounted(async () => {
|
||||
await initHomeLandingLiveChart()
|
||||
bindLiveChartDraw()
|
||||
})
|
||||
</script>
|
||||
@@ -2,7 +2,8 @@ import { ref } from 'vue'
|
||||
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { clearProductsCatalog } from '@/composables/useProductsCatalog'
|
||||
import { fetchHomeChartPrices, loadHomeMaftolOffset, resetHomePriceChart } from './usePriceChart'
|
||||
import { initHomeLandingLiveChart, resetHomeLandingLiveChart } from './useLandingLiveChart'
|
||||
import { loadHomeMaftolOffset, resetHomePriceChart } from './usePriceChart'
|
||||
import { loadHomeBanners, resetHomeBanners } from './useBanners'
|
||||
import { loadHomeFunds, resetHomeFunds } from './useUserFunds'
|
||||
|
||||
@@ -40,7 +41,7 @@ export async function initHomePageData(force = false, options?: { guest?: boolea
|
||||
|
||||
const sharedTasks = [
|
||||
getLastPrices(),
|
||||
fetchHomeChartPrices(force),
|
||||
initHomeLandingLiveChart(),
|
||||
startLastPricePolling(),
|
||||
loadHomeBanners(force),
|
||||
]
|
||||
@@ -73,6 +74,7 @@ export function resetHomePageData() {
|
||||
resetHomeBanners()
|
||||
resetHomeFunds()
|
||||
resetHomePriceChart()
|
||||
resetHomeLandingLiveChart()
|
||||
clearProductsCatalog()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { Price } from '@/models/home'
|
||||
import {
|
||||
fetchZaanboxPriceHistory,
|
||||
type LandingHistoryRange,
|
||||
type LandingHistorySample,
|
||||
} from '@/api/zaarboxPriceHistory'
|
||||
import useAxios from '@/utils/useAxios'
|
||||
import { applyLastPricePayload } from '@/utils/price'
|
||||
import { getPriceDisplayState, hasLivePriceQuote, isStoreClosed } from '@/utils/storeHours'
|
||||
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
|
||||
import { applyChartPriceOffset, resolvePriceOffsetRatio } from '@/utils/productPrice'
|
||||
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
|
||||
import type { Product } from '@/models/products'
|
||||
import {
|
||||
CHART_METAL_MARKUP_RATIO,
|
||||
type ChartMetalType,
|
||||
usePriceChart,
|
||||
} from './usePriceChart'
|
||||
|
||||
const LIVE_CHART_WIDTH = 560
|
||||
const LIVE_CHART_HEIGHT = 160
|
||||
const CHART_PAD = 10
|
||||
const MAX_CHART_POINTS = 48
|
||||
const PRICE_POLL_MS = 30_000
|
||||
|
||||
type ChartHoverState = {
|
||||
xPercent: number
|
||||
yPercent: number
|
||||
priceLabel: string
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
export const LIVE_CHART_RANGE_OPTIONS: Array<{
|
||||
value: LandingHistoryRange
|
||||
shortLabel: string
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'DAILY', shortLabel: '۲۴ ساعت', label: 'نمودار ۲۴ ساعت گذشته' },
|
||||
{ value: 'WEEKLY', shortLabel: 'هفته', label: 'نمودار هفته گذشته' },
|
||||
{ value: 'MONTHLY', shortLabel: 'ماه', label: 'نمودار ماه گذشته' },
|
||||
]
|
||||
|
||||
const activeProductId = ref<number | null>(null)
|
||||
const catalogProducts = getProductsCatalogRef()
|
||||
const liveChartRange = ref<LandingHistoryRange>('DAILY')
|
||||
const dailyRawPoints = ref<LandingHistorySample[]>([])
|
||||
const liveRangeRawPoints = ref<LandingHistorySample[]>([])
|
||||
const liveChartLoading = ref(false)
|
||||
const liveChartHover = ref<ChartHoverState | null>(null)
|
||||
const liveChartDrawn = ref(false)
|
||||
const liveChartPlotEl = ref<HTMLElement | null>(null)
|
||||
|
||||
let pricePollTimer: ReturnType<typeof setInterval> | undefined
|
||||
let liveChartObserver: IntersectionObserver | undefined
|
||||
let initialized = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
function isListedChartProduct(product: Product) {
|
||||
return !product.deleted_at
|
||||
}
|
||||
|
||||
const chartProductTabs = computed(() => catalogProducts.value.filter(isListedChartProduct))
|
||||
|
||||
const selectedChartProduct = computed(
|
||||
() => chartProductTabs.value.find((product) => product.id === activeProductId.value) ?? null,
|
||||
)
|
||||
|
||||
function metalFromProductTitle(title: string): ChartMetalType {
|
||||
if (title.includes('شمش')) return 'shamsh'
|
||||
if (title.includes('مفتول')) return 'maftol'
|
||||
return 'katod'
|
||||
}
|
||||
|
||||
watch(chartProductTabs, (tabs) => {
|
||||
if (!tabs.length) {
|
||||
activeProductId.value = null
|
||||
return
|
||||
}
|
||||
if (!tabs.some((product) => product.id === activeProductId.value)) {
|
||||
activeProductId.value = tabs[0].id
|
||||
}
|
||||
})
|
||||
|
||||
function markupPriceForProduct(basePrice: number, product: Product | null) {
|
||||
if (!product || !basePrice) return basePrice
|
||||
if (resolvePriceOffsetRatio(product.price_offset_percent)) {
|
||||
return applyChartPriceOffset(basePrice, product.price_offset_percent)
|
||||
}
|
||||
const metalRatio = CHART_METAL_MARKUP_RATIO[metalFromProductTitle(product.title)]
|
||||
if (!metalRatio) return basePrice
|
||||
return Math.round(basePrice + basePrice * metalRatio)
|
||||
}
|
||||
|
||||
function applyMarkupToSeries(
|
||||
points: LandingHistorySample[],
|
||||
product: Product | null,
|
||||
): LandingHistorySample[] {
|
||||
return points
|
||||
.map((point) => ({
|
||||
timeMs: point.timeMs,
|
||||
price: markupPriceForProduct(point.price, product),
|
||||
}))
|
||||
.filter((point) => Number.isFinite(point.price) && point.price > 0)
|
||||
}
|
||||
|
||||
function downsampleSamples(points: LandingHistorySample[], maxPoints: number) {
|
||||
if (points.length <= maxPoints) return points
|
||||
const result: LandingHistorySample[] = []
|
||||
const lastIndex = points.length - 1
|
||||
for (let i = 0; i < maxPoints; i += 1) {
|
||||
const index = Math.round((i / (maxPoints - 1)) * lastIndex)
|
||||
result.push(points[index])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function formatChartTimeLabel(timeMs: number) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(timeMs))
|
||||
} catch {
|
||||
return new Date(timeMs).toLocaleString('fa-IR')
|
||||
}
|
||||
}
|
||||
|
||||
type ChartPlotSample = { x: number; y: number; price: number; timeMs: number }
|
||||
|
||||
function buildLandingChartPaths(
|
||||
points: LandingHistorySample[],
|
||||
width = LIVE_CHART_WIDTH,
|
||||
height = LIVE_CHART_HEIGHT,
|
||||
) {
|
||||
const empty = {
|
||||
hasData: false,
|
||||
linePath: '',
|
||||
areaPath: '',
|
||||
lastX: 0,
|
||||
lastY: height / 2,
|
||||
samples: [] as ChartPlotSample[],
|
||||
}
|
||||
if (points.length < 2) return empty
|
||||
|
||||
const sampled = downsampleSamples(points, MAX_CHART_POINTS)
|
||||
const prices = sampled.map((point) => point.price)
|
||||
const min = Math.min(...prices)
|
||||
const max = Math.max(...prices)
|
||||
const range = max - min || 1
|
||||
const usableHeight = height - CHART_PAD * 2
|
||||
|
||||
const plotSamples = sampled.map((point, index) => {
|
||||
const x = (index / (sampled.length - 1)) * width
|
||||
const y = CHART_PAD + (1 - (point.price - min) / range) * usableHeight
|
||||
return { x, y, price: point.price, timeMs: point.timeMs }
|
||||
})
|
||||
|
||||
const linePath = plotSamples
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
|
||||
.join(' ')
|
||||
const last = plotSamples[plotSamples.length - 1]
|
||||
const areaPath = `${linePath} L${width} ${height} L0 ${height} Z`
|
||||
|
||||
return {
|
||||
hasData: true,
|
||||
linePath,
|
||||
areaPath,
|
||||
lastX: last.x,
|
||||
lastY: last.y,
|
||||
samples: plotSamples,
|
||||
}
|
||||
}
|
||||
|
||||
const chartPoints = computed(() =>
|
||||
applyMarkupToSeries(dailyRawPoints.value, selectedChartProduct.value),
|
||||
)
|
||||
const chartValues = computed(() => chartPoints.value.map((point) => point.price))
|
||||
|
||||
const liveSectionChartPoints = computed(() => {
|
||||
const raw =
|
||||
liveChartRange.value === 'DAILY' && liveRangeRawPoints.value.length < 2
|
||||
? dailyRawPoints.value
|
||||
: liveRangeRawPoints.value
|
||||
return applyMarkupToSeries(raw, selectedChartProduct.value)
|
||||
})
|
||||
|
||||
const motionPoints = ref<LandingHistorySample[] | null>(null)
|
||||
let chartMotionFrame = 0
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
function sampleSeriesAt(points: LandingHistorySample[], index: number, count: number) {
|
||||
if (points.length <= 1) return points[0]
|
||||
const ratio = count <= 1 ? 0 : index / (count - 1)
|
||||
const position = ratio * (points.length - 1)
|
||||
const start = Math.floor(position)
|
||||
const blend = position - start
|
||||
const from = points[start]
|
||||
const to = points[Math.min(start + 1, points.length - 1)]
|
||||
return from.price + (to.price - from.price) * blend
|
||||
}
|
||||
|
||||
function easeOutQuart(progress: number) {
|
||||
return 1 - (1 - progress) ** 4
|
||||
}
|
||||
|
||||
function animateChartSeries(next: LandingHistorySample[]) {
|
||||
cancelAnimationFrame(chartMotionFrame)
|
||||
const from = motionPoints.value
|
||||
if (!from || from.length < 2 || next.length < 2 || prefersReducedMotion()) {
|
||||
motionPoints.value = next
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
const duration = 340
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / duration)
|
||||
const eased = easeOutQuart(progress)
|
||||
motionPoints.value = next.map((point, index) => {
|
||||
const origin = sampleSeriesAt(from, index, next.length)
|
||||
return {
|
||||
timeMs: point.timeMs,
|
||||
price: origin + (point.price - origin) * eased,
|
||||
}
|
||||
})
|
||||
if (progress < 1) chartMotionFrame = requestAnimationFrame(tick)
|
||||
}
|
||||
chartMotionFrame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
watch(activeProductId, () => {
|
||||
liveChartHover.value = null
|
||||
const next = liveSectionChartPoints.value
|
||||
if (next.length >= 2 && motionPoints.value?.length) {
|
||||
animateChartSeries(next)
|
||||
return
|
||||
}
|
||||
motionPoints.value = next.length ? next : null
|
||||
})
|
||||
|
||||
watch(liveSectionChartPoints, (next, prev) => {
|
||||
if (!prev?.length) {
|
||||
motionPoints.value = next
|
||||
return
|
||||
}
|
||||
const prevLast = prev[prev.length - 1]
|
||||
const nextLast = next[next.length - 1]
|
||||
const unchanged =
|
||||
prev.length === next.length &&
|
||||
prev[0]?.timeMs === next[0]?.timeMs &&
|
||||
prevLast?.timeMs === nextLast?.timeMs &&
|
||||
prevLast?.price === nextLast?.price
|
||||
if (unchanged) return
|
||||
animateChartSeries(next)
|
||||
})
|
||||
|
||||
const displayedChartPoints = computed(() => motionPoints.value ?? liveSectionChartPoints.value)
|
||||
const liveSectionChart = computed(() => buildLandingChartPaths(displayedChartPoints.value))
|
||||
|
||||
const baseBuyPrice = computed(() => {
|
||||
const lastDaily = dailyRawPoints.value[dailyRawPoints.value.length - 1]?.price
|
||||
if (Number.isFinite(lastDaily) && lastDaily > 0) return lastDaily
|
||||
const lastLive = liveRangeRawPoints.value[liveRangeRawPoints.value.length - 1]?.price
|
||||
if (Number.isFinite(lastLive) && lastLive > 0) return lastLive
|
||||
return 0
|
||||
})
|
||||
|
||||
const liveUnitPrice = computed(() =>
|
||||
markupPriceForProduct(baseBuyPrice.value, selectedChartProduct.value),
|
||||
)
|
||||
|
||||
const formattedLivePrice = computed(() => {
|
||||
if (!liveUnitPrice.value) return '—'
|
||||
return usePriceWithoutType(liveUnitPrice.value)
|
||||
})
|
||||
|
||||
const liveMarketState = computed(() => {
|
||||
if (isStoreClosed()) return 'closed' as const
|
||||
const state = getPriceDisplayState()
|
||||
if (state === 'open') return 'open' as const
|
||||
if (baseBuyPrice.value > 0) return 'open' as const
|
||||
if (!hasLivePriceQuote()) return 'no_quote' as const
|
||||
return 'closed' as const
|
||||
})
|
||||
|
||||
const liveMarketStatusLabel = computed(() => {
|
||||
if (liveMarketState.value === 'open') return 'بازار باز است'
|
||||
if (liveMarketState.value === 'no_quote') return 'در انتظار قیمت'
|
||||
return 'بازار بسته است'
|
||||
})
|
||||
|
||||
const liveMarketStatusIcon = computed(() => {
|
||||
if (liveMarketState.value === 'open') return 'vuesax-linear:tick-circle'
|
||||
if (liveMarketState.value === 'no_quote') return 'vuesax-linear:info-circle'
|
||||
return 'vuesax-linear:lock'
|
||||
})
|
||||
|
||||
const liveMarketStatusColor = computed(() => {
|
||||
if (liveMarketState.value === 'open') return '#16a34a'
|
||||
if (liveMarketState.value === 'no_quote') return '#94a3b8'
|
||||
return '#dc2626'
|
||||
})
|
||||
|
||||
const liveUpdateLabel = computed(() => {
|
||||
if (liveMarketState.value === 'open') return 'بهروزرسانی لحظهای'
|
||||
if (liveMarketState.value === 'closed') return 'خارج از ساعت معامله'
|
||||
return 'قیمت دریافت نشده'
|
||||
})
|
||||
|
||||
const liveProductLabel = computed(() => selectedChartProduct.value?.title ?? '')
|
||||
|
||||
const liveChartRangeLabel = computed(
|
||||
() =>
|
||||
LIVE_CHART_RANGE_OPTIONS.find((option) => option.value === liveChartRange.value)?.label ??
|
||||
'نمودار ۲۴ ساعت گذشته',
|
||||
)
|
||||
|
||||
const dayChangePercent = computed(() => {
|
||||
const values = chartValues.value
|
||||
if (values.length >= 2 && values[0] > 0) {
|
||||
return ((values[values.length - 1] - values[0]) / values[0]) * 100
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const dayChangeAmountLabel = computed(() => {
|
||||
const values = chartValues.value
|
||||
if (values.length < 2) return ''
|
||||
const delta = Math.round(values[values.length - 1] - values[0])
|
||||
if (!delta) return ''
|
||||
const abs = usePriceWithoutType(Math.abs(delta))
|
||||
return `${delta > 0 ? '+' : '−'}${abs} ریال`
|
||||
})
|
||||
|
||||
function resolveHoverFromEvent(
|
||||
event: PointerEvent,
|
||||
chart: { samples: ChartPlotSample[]; hasData: boolean },
|
||||
width: number,
|
||||
height: number,
|
||||
): ChartHoverState | null {
|
||||
if (!chart.hasData || chart.samples.length < 2) return null
|
||||
const target = event.currentTarget as HTMLElement | null
|
||||
if (!target) return null
|
||||
const rect = target.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
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 null
|
||||
return {
|
||||
xPercent: (sample.x / width) * 100,
|
||||
yPercent: (sample.y / height) * 100,
|
||||
priceLabel: `${usePriceWithoutType(sample.price)} ریال`,
|
||||
timeLabel: formatChartTimeLabel(sample.timeMs),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLandingMarketStatus() {
|
||||
try {
|
||||
const data = await useAxios.get<Price>('/api/price/last', {
|
||||
meta: { errorMessage: 'none' },
|
||||
})
|
||||
if (data) applyLastPricePayload(data)
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDailyHistory() {
|
||||
try {
|
||||
const series = await fetchZaanboxPriceHistory('DAILY')
|
||||
if (series.length >= 2) {
|
||||
dailyRawPoints.value = series
|
||||
if (liveChartRange.value === 'DAILY') liveRangeRawPoints.value = series
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Home live chart daily history failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLiveRangeHistory(range: LandingHistoryRange) {
|
||||
liveChartLoading.value = true
|
||||
try {
|
||||
const series = await fetchZaanboxPriceHistory(range)
|
||||
liveRangeRawPoints.value = series.length >= 2 ? series : []
|
||||
if (range === 'DAILY' && series.length >= 2) dailyRawPoints.value = series
|
||||
} catch (error) {
|
||||
console.error(`Home live chart ${range} history failed:`, error)
|
||||
liveRangeRawPoints.value = []
|
||||
} finally {
|
||||
liveChartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLandingChart() {
|
||||
liveChartLoading.value = true
|
||||
try {
|
||||
await loadDailyHistory()
|
||||
if (liveChartRange.value !== 'DAILY') {
|
||||
await loadLiveRangeHistory(liveChartRange.value)
|
||||
} else if (liveRangeRawPoints.value.length < 2) {
|
||||
liveRangeRawPoints.value = dailyRawPoints.value
|
||||
}
|
||||
} finally {
|
||||
liveChartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setupLiveChartDraw() {
|
||||
if (liveChartDrawn.value || !liveChartPlotEl.value) return
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
liveChartDrawn.value = true
|
||||
return
|
||||
}
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
liveChartDrawn.value = true
|
||||
return
|
||||
}
|
||||
liveChartObserver?.disconnect()
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
liveChartDrawn.value = true
|
||||
observer.disconnect()
|
||||
liveChartObserver = undefined
|
||||
}
|
||||
},
|
||||
{ threshold: 0.35 },
|
||||
)
|
||||
liveChartObserver = observer
|
||||
observer.observe(liveChartPlotEl.value)
|
||||
}
|
||||
|
||||
function setLiveProduct(productId: number) {
|
||||
if (!chartProductTabs.value.some((product) => product.id === productId)) return
|
||||
activeProductId.value = productId
|
||||
liveChartHover.value = null
|
||||
const product = selectedChartProduct.value
|
||||
if (!product) return
|
||||
const { setChartMetal } = usePriceChart()
|
||||
setChartMetal(metalFromProductTitle(product.title))
|
||||
}
|
||||
|
||||
async function setLiveChartRange(range: LandingHistoryRange) {
|
||||
if (liveChartRange.value === range && liveRangeRawPoints.value.length >= 2) return
|
||||
liveChartRange.value = range
|
||||
liveChartHover.value = null
|
||||
await loadLiveRangeHistory(range)
|
||||
}
|
||||
|
||||
function onLiveChartPointerMove(event: PointerEvent) {
|
||||
liveChartHover.value = resolveHoverFromEvent(
|
||||
event,
|
||||
liveSectionChart.value,
|
||||
LIVE_CHART_WIDTH,
|
||||
LIVE_CHART_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
function onLiveChartPointerLeave() {
|
||||
liveChartHover.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => liveSectionChart.value.hasData,
|
||||
async (hasData) => {
|
||||
if (!hasData || liveChartDrawn.value) return
|
||||
await nextTick()
|
||||
setupLiveChartDraw()
|
||||
},
|
||||
)
|
||||
|
||||
export async function initHomeLandingLiveChart() {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
if (initialized) return
|
||||
await getProductsCatalog()
|
||||
const firstProduct = chartProductTabs.value[0]
|
||||
if (firstProduct) setLiveProduct(firstProduct.id)
|
||||
await loadLandingMarketStatus()
|
||||
await loadLandingChart()
|
||||
if (!pricePollTimer) {
|
||||
pricePollTimer = setInterval(() => {
|
||||
void loadLandingMarketStatus()
|
||||
void loadDailyHistory()
|
||||
if (liveChartRange.value !== 'DAILY') void loadLiveRangeHistory(liveChartRange.value)
|
||||
}, PRICE_POLL_MS)
|
||||
}
|
||||
initialized = true
|
||||
await nextTick()
|
||||
setupLiveChartDraw()
|
||||
})()
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export function resetHomeLandingLiveChart() {
|
||||
initialized = false
|
||||
initPromise = null
|
||||
if (pricePollTimer) {
|
||||
clearInterval(pricePollTimer)
|
||||
pricePollTimer = undefined
|
||||
}
|
||||
liveChartObserver?.disconnect()
|
||||
liveChartObserver = undefined
|
||||
cancelAnimationFrame(chartMotionFrame)
|
||||
motionPoints.value = null
|
||||
dailyRawPoints.value = []
|
||||
liveRangeRawPoints.value = []
|
||||
liveChartHover.value = null
|
||||
liveChartDrawn.value = false
|
||||
activeProductId.value = null
|
||||
liveChartRange.value = 'DAILY'
|
||||
}
|
||||
|
||||
export function bindLiveChartDraw() {
|
||||
void nextTick(() => setupLiveChartDraw())
|
||||
}
|
||||
|
||||
export function useLandingLiveChart() {
|
||||
return {
|
||||
activeProductId,
|
||||
chartProductTabs,
|
||||
liveChartRange,
|
||||
liveChartRangeOptions: LIVE_CHART_RANGE_OPTIONS,
|
||||
liveChartLoading,
|
||||
liveChartHover,
|
||||
liveChartDrawn,
|
||||
liveChartPlotEl,
|
||||
liveSectionChart,
|
||||
formattedLivePrice,
|
||||
liveProductLabel,
|
||||
liveChartRangeLabel,
|
||||
liveMarketState,
|
||||
liveMarketStatusLabel,
|
||||
liveMarketStatusIcon,
|
||||
liveMarketStatusColor,
|
||||
liveUpdateLabel,
|
||||
dayChangePercent,
|
||||
dayChangeAmountLabel,
|
||||
formatSignedPercentChange,
|
||||
setLiveProduct,
|
||||
setLiveChartRange,
|
||||
onLiveChartPointerMove,
|
||||
onLiveChartPointerLeave,
|
||||
initHomeLandingLiveChart,
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useHomePageContext } from '../../composables/useHomePage'
|
||||
import PriceChart from '../../components/PriceChart.vue'
|
||||
import LandingLiveChartMain from '../../components/LandingLiveChartMain.vue'
|
||||
import DesktopHomeCard from '../components/DesktopHomeCard.vue'
|
||||
|
||||
const ctx = useHomePageContext()
|
||||
|
||||
const chartLayout = 'boursecu' as const
|
||||
const priceChartSeries = computed(() => {
|
||||
const series = toValue(ctx.priceChartSeries)
|
||||
void series?.[0]?.data?.length
|
||||
return series
|
||||
})
|
||||
const chartSelection = computed(() => toValue(ctx.chartSelection))
|
||||
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopHomeCard title="نمودار قیمت">
|
||||
<PriceChart
|
||||
variant="desktop"
|
||||
:price-chart-series="priceChartSeries"
|
||||
:layout="chartLayout"
|
||||
:chart-selection="chartSelection"
|
||||
:selected-metal="selectedChartMetal"
|
||||
@update-data="ctx.updateChartData"
|
||||
@update:selected-metal="ctx.setChartMetal"
|
||||
/>
|
||||
<LandingLiveChartMain />
|
||||
</DesktopHomeCard>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useHomePageContext } from '../../composables/useHomePage'
|
||||
import PriceChart from '../../components/PriceChart.vue'
|
||||
import LandingLiveChartMain from '../../components/LandingLiveChartMain.vue'
|
||||
import DesktopHomeSection from './DesktopHomeSection.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -12,17 +10,6 @@ const props = withDefaults(
|
||||
embedded: false,
|
||||
},
|
||||
)
|
||||
|
||||
const ctx = useHomePageContext()
|
||||
|
||||
const chartLayout = 'boursecu' as const
|
||||
const priceChartSeries = computed(() => {
|
||||
const series = toValue(ctx.priceChartSeries)
|
||||
void series?.[0]?.data?.length
|
||||
return series
|
||||
})
|
||||
const chartSelection = computed(() => toValue(ctx.chartSelection))
|
||||
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,14 +18,6 @@ const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
|
||||
:title="props.embedded ? undefined : 'نمودار قیمت'"
|
||||
class="dhome-chart"
|
||||
>
|
||||
<PriceChart
|
||||
variant="desktop"
|
||||
:price-chart-series="priceChartSeries"
|
||||
:layout="chartLayout"
|
||||
:chart-selection="chartSelection"
|
||||
:selected-metal="selectedChartMetal"
|
||||
@update-data="ctx.updateChartData"
|
||||
@update:selected-metal="ctx.setChartMetal"
|
||||
/>
|
||||
<LandingLiveChartMain />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -84,7 +84,7 @@ const statusHint = computed(() => {
|
||||
if (displayState.value === 'closed') {
|
||||
return getStoreClosedHint(userStore.getValueByKey('closed_message'))
|
||||
}
|
||||
return 'در حال حاضر مظنهای برای نمایش وجود ندارد.'
|
||||
return 'در حال حاضر قیمتی برای نمایش وجود ندارد.'
|
||||
})
|
||||
|
||||
const displayGoldVal = computed(() => {
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, toValue } from 'vue'
|
||||
import PriceChart from '../../components/PriceChart.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
const layout = 'boursecu' as const
|
||||
const priceChartSeries = computed(() => {
|
||||
const series = toValue(ctx.priceChartSeries)
|
||||
// Track length so chart updates even if the array reference is reused.
|
||||
void series?.[0]?.data?.length
|
||||
return series
|
||||
})
|
||||
const chartSelection = computed(() => toValue(ctx.chartSelection))
|
||||
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
|
||||
import LandingLiveChartMain from '../../components/LandingLiveChartMain.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PriceChart
|
||||
:price-chart-series="priceChartSeries"
|
||||
:layout="layout"
|
||||
:chart-selection="chartSelection"
|
||||
:selected-metal="selectedChartMetal"
|
||||
@update-data="ctx.updateChartData"
|
||||
@update:selected-metal="ctx.setChartMetal"
|
||||
/>
|
||||
<LandingLiveChartMain />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
@keyframes dlanding-chart-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dlanding-chart-fade {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dlanding-live.dlanding-live--home-embed {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
|
||||
.dlanding-live__main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: clamp(16px, 2.4vw, 24px);
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -48px;
|
||||
inset-inline-start: -36px;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(232, 180, 140, 0.35) 0%, rgba(232, 180, 140, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dlanding-live__head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dlanding-live__product-tabs,
|
||||
.dlanding-live__range-tabs {
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
gap: 2px;
|
||||
|
||||
button {
|
||||
min-width: 52px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.22s ease, color 0.22s ease;
|
||||
}
|
||||
|
||||
.is-active {
|
||||
background: #c98a5f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.dlanding-live__product-tabs {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dlanding-live__product-tabs button {
|
||||
min-width: 64px;
|
||||
max-width: 11rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dlanding-live__price-block {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dlanding-live__price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.dlanding-live__price {
|
||||
color: #1a2b47;
|
||||
font-size: clamp(28px, 6vw, 44px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.dlanding-live__unit {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dlanding-live__change {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dlanding-live__change-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: #16a34a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
direction: ltr;
|
||||
|
||||
&.is-down {
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.dlanding-live__change-label {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.dlanding-live__chart-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-caption {
|
||||
min-width: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dlanding-live__chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: clamp(148px, 32vw, 180px);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-path {
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-area,
|
||||
.dlanding-live__chart-endpoint {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dlanding-live__chart.is-drawn {
|
||||
.dlanding-live__chart-path {
|
||||
animation: dlanding-chart-draw 1.55s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-area {
|
||||
animation: dlanding-chart-fade 0.55s ease-out 0.85s forwards;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-endpoint {
|
||||
animation: dlanding-chart-fade 0.35s ease-out 1.35s forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.dlanding-live__chart-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dlanding-live__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;
|
||||
}
|
||||
|
||||
.dlanding-live__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;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-tooltip {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 132px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 12px;
|
||||
background: rgba(26, 43, 71, 0.94);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-near-end {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.dlanding-live__price-block {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dlanding-live__change {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dlanding-live__chart-caption {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div :class="desktop ? 'dgift' : ''">
|
||||
<div :class="desktop ? 'dgift__container' : ''">
|
||||
<header v-if="desktop" class="dgift-topbar">
|
||||
<button type="button" class="dgift-topbar__back" @click="router.back()">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
|
||||
<span>بازگشت</span>
|
||||
</button>
|
||||
<div class="dgift-topbar__meta">
|
||||
<p class="dgift-topbar__eyebrow">خرید</p>
|
||||
<h1 class="dgift-topbar__title">خرید اقساطی مس</h1>
|
||||
</div>
|
||||
</header>
|
||||
<MobilePageHeader v-else title="خرید اقساطی مس" show-back />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div :class="desktop ? 'dgift-layout' : 'px-5 mpage-content pb-6 flex flex-col-reverse'">
|
||||
<section :class="desktop ? 'dgift-surface dgift-form' : 'border-gray-200 border-1 rounded-xl p-4'">
|
||||
<div v-if="desktop" class="dgift-form__header">
|
||||
<div class="dgift-form__icon">
|
||||
<BasicIcon icon="vuesax-linear:calendar" size="28" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="dgift-form__title">خرید مس قسطی</h2>
|
||||
<p class="dgift-form__subtitle">طرح را انتخاب کنید و مبلغ خرید را به تومان وارد کنید.</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="font-bold mb-1">خرید مس قسطی</div>
|
||||
<p class="text-xs text-gray-500 mb-3">طرح را انتخاب کنید و مبلغ خرید را به تومان وارد کنید.</p>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="plan"
|
||||
:class="{ 'plan--on': plan === 4 }"
|
||||
@click="plan = 4"
|
||||
>
|
||||
<b>اعتبار ۴ قسط</b>
|
||||
<span>تا {{ money(status?.config.plan4_cap_toman) }} تومان</span>
|
||||
<span v-if="active">مانده {{ money(status?.plan4_remaining_toman) }} تومان</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="plan"
|
||||
:class="{ 'plan--on': plan === 12 }"
|
||||
@click="plan = 12"
|
||||
>
|
||||
<b>وام ۱۲ قسط</b>
|
||||
<span>تا {{ money(status?.config.plan12_cap_toman) }} تومان · یک چک</span>
|
||||
<span v-if="active">مانده {{ money(status?.plan12_remaining_toman) }} تومان</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p :class="desktop ? 'dgift-form__hint' : 'text-xs text-gray-500 mb-2'">
|
||||
خرید با {{ feeShop }}٪ کارمزد ({{ feeGateway }}٪ دیجیپی + {{ feeExtra }}٪) محاسبه میشود.
|
||||
</p>
|
||||
<BasicInput
|
||||
v-model:value="amountText"
|
||||
place-holder="مبلغ مورد نظر (تومان)"
|
||||
size="large"
|
||||
type="tel"
|
||||
/>
|
||||
<p :class="desktop ? 'dgift-form__hint' : 'text-xs text-gray-500 mt-2'">
|
||||
{{ gramsPreview || 'بعد از وارد کردن مبلغ، مقدار مس همینجا دیده میشود.' }}
|
||||
</p>
|
||||
<BasicButton
|
||||
:class="desktop ? 'dgift-form__submit' : 'w-full py-2 mt-3'"
|
||||
type="primary"
|
||||
:disabled="buying"
|
||||
@click="buy"
|
||||
>
|
||||
<a-spin v-if="buying" size="small" />
|
||||
<span v-else>خرید</span>
|
||||
</BasicButton>
|
||||
</section>
|
||||
|
||||
<aside :class="desktop ? 'dgift-surface dgift-guide' : 'border-gray-200 border-1 rounded-xl p-4 mb-3'">
|
||||
<h3 :class="desktop ? 'dgift-guide__title' : 'font-bold mb-2'">خرید قسطی با دیجیپی</h3>
|
||||
<p :class="desktop ? 'dgift-form__hint' : 'text-xs text-gray-500 mb-2'">
|
||||
احراز هویت در دیجیپی انجام میشود. بعد از تأیید، اعتبار روی همین صفحه مینشیند.
|
||||
</p>
|
||||
<ol :class="desktop ? 'dgift-guide__steps' : 'installment-steps'">
|
||||
<li>خرید آنی مس بدون پرداخت نقدی</li>
|
||||
<li>بدون پیشپرداخت و کاملاً آنلاین</li>
|
||||
<li>امکان نقد کردن مس خریداریشده</li>
|
||||
</ol>
|
||||
<div :class="desktop ? 'dgift-guide__note' : 'text-xs text-gray-500 mt-2'">
|
||||
<p v-if="status?.status === 'pending'">احراز را تمام کنید. مانده بعد از ثبت پشتیبانی اینجا میآید.</p>
|
||||
<p v-else-if="active">اعتبار شما فعال است. طرح را انتخاب کنید و خرید را بزنید.</p>
|
||||
<p v-else>برای شروع، احراز دیجیپی را باز کنید.</p>
|
||||
</div>
|
||||
<BasicButton
|
||||
v-if="!active"
|
||||
:class="desktop ? 'dgift-form__submit' : 'w-full py-2 mt-3'"
|
||||
type="primary"
|
||||
:disabled="opening"
|
||||
@click="goDigipay"
|
||||
>
|
||||
رفتن به دیجیپی
|
||||
</BasicButton>
|
||||
</aside>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
||||
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
|
||||
import router from '@/router'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import {
|
||||
buyDigipayInstallment,
|
||||
fetchDigipayInstallment,
|
||||
startDigipayInstallment,
|
||||
type DigipayInstallmentStatus
|
||||
} from '@/api/digipayInstallment'
|
||||
|
||||
defineProps<{ desktop?: boolean }>()
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const loading = ref(false)
|
||||
const opening = ref(false)
|
||||
const buying = ref(false)
|
||||
const status = ref<DigipayInstallmentStatus | null>(null)
|
||||
const plan = ref<4 | 12>(4)
|
||||
const amountText = ref('')
|
||||
|
||||
const active = computed(() => status.value?.status === 'active')
|
||||
const feeShop = computed(() => status.value?.config.fee_shop_percent ?? 8)
|
||||
const feeGateway = computed(() => status.value?.config.fee_gateway_percent ?? 5.7)
|
||||
const feeExtra = computed(() => status.value?.config.fee_extra_percent ?? 2.3)
|
||||
const amountToman = computed(() => Number(String(amountText.value).replace(/[^\d]/g, '')) || 0)
|
||||
|
||||
const gramsPreview = computed(() => {
|
||||
const price = status.value?.gram_price_rial || 0
|
||||
if (!price || amountToman.value < 1000) return ''
|
||||
const grams = (amountToman.value * 10) / price
|
||||
return `حدود ${grams.toLocaleString('fa-IR', { maximumFractionDigits: 3 })} ${settings.tradeWeightUnit} به کیف مینشیند.`
|
||||
})
|
||||
|
||||
function money(value?: number) {
|
||||
return Number(value || 0).toLocaleString('fa-IR')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchDigipayInstallment()
|
||||
status.value = (res as any)?.data ?? res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goDigipay() {
|
||||
opening.value = true
|
||||
try {
|
||||
const res = await startDigipayInstallment()
|
||||
status.value = (res as any)?.data ?? res
|
||||
const url = status.value?.config.onboarding_url
|
||||
if (!url) {
|
||||
message.error('لینک دیجیپی هنوز تنظیم نشده')
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'شروع احراز انجام نشد')
|
||||
} finally {
|
||||
opening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function buy() {
|
||||
if (!active.value) {
|
||||
message.warning('اول احراز دیجیپی را تمام کنید تا اعتبارتان ثبت شود')
|
||||
return
|
||||
}
|
||||
buying.value = true
|
||||
try {
|
||||
const res = await buyDigipayInstallment(plan.value, amountToman.value)
|
||||
const data = (res as any)?.data ?? res
|
||||
message.success(`خرید ثبت شد. ${data.grams} به کیف اضافه شد`)
|
||||
amountText.value = ''
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خرید انجام نشد')
|
||||
} finally {
|
||||
buying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plan {
|
||||
text-align: right;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
}
|
||||
.plan b {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
}
|
||||
.plan--on {
|
||||
border-color: var(--app-primary-color, #093d51);
|
||||
box-shadow: inset 0 0 0 1px var(--app-primary-color, #093d51);
|
||||
}
|
||||
.installment-steps {
|
||||
margin: 0;
|
||||
padding: 0 18px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="installment-root">
|
||||
<InstallmentPanel :desktop="isDesktop" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import InstallmentPanel from './InstallmentPanel.vue'
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.installment-root {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { getUserAvatarUrl } from '@/utils/file'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { getBoursecuActionIconSrc } from '@/utils/config'
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { getReferralShareText, getReferralShareTitle, shareReferralText } from '@/utils/referralShare'
|
||||
import { normalizeAuthFormFields } from '@/utils/persianDigits'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
@@ -37,6 +38,7 @@ export interface UserMenuAction {
|
||||
onClick: () => void
|
||||
visible: boolean
|
||||
badge?: number
|
||||
badgeLabel?: string
|
||||
}
|
||||
|
||||
export interface UserPageContext {
|
||||
@@ -82,6 +84,7 @@ export function useUserPage() {
|
||||
const walletStore = useWalletStore()
|
||||
const { clearWalletAssets } = useWalletAssets()
|
||||
const settingsStore = useSettingsStore()
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const nameProject = useConfigStore().config.name_fa
|
||||
const editPassVisible = ref(false)
|
||||
const editPasswordVisible = ref(false)
|
||||
@@ -146,10 +149,19 @@ export function useUserPage() {
|
||||
onClick: () => router.push({ name: 'piggy' }),
|
||||
visible: settingsStore.isSettingTruthy('enable_product_funds'),
|
||||
},
|
||||
{
|
||||
id: 'notifyTrigger',
|
||||
label: 'هشدار قیمت',
|
||||
icon: 'vuesax-linear:bill',
|
||||
iconSrc: getBoursecuActionIconSrc('priceTrigger'),
|
||||
onClick: () => router.push({ name: 'notifyTrigger' }),
|
||||
visible: !isDesktop.value && settingsStore.isSettingTruthy('has_page_price_trigger'),
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'درباره ما',
|
||||
icon: 'vuesax-linear:info-circle',
|
||||
iconSrc: getBoursecuActionIconSrc('about'),
|
||||
onClick: () => router.push({ name: 'about' }),
|
||||
visible: true,
|
||||
},
|
||||
@@ -170,6 +182,14 @@ export function useUserPage() {
|
||||
onClick: () => router.push({ name: 'physicalReceive' }),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'installment',
|
||||
label: 'خرید اقساطی',
|
||||
icon: 'vuesax-linear:calendar',
|
||||
onClick: () => router.push({ name: 'installment' }),
|
||||
visible: settingsStore.isSettingTruthy('enable_digipay_installment'),
|
||||
badgeLabel: 'جدید',
|
||||
},
|
||||
{
|
||||
id: 'giftCard',
|
||||
label: 'ثبت کارت هدیه',
|
||||
|
||||
@@ -82,10 +82,10 @@
|
||||
/>
|
||||
<BasicIcon v-else :icon="action.icon" size="18" />
|
||||
<span
|
||||
v-if="action.badge && action.badge > 0"
|
||||
v-if="action.badgeLabel || (action.badge && action.badge > 0)"
|
||||
class="duser-actions__badge"
|
||||
>
|
||||
{{ action.badge > 99 ? '99+' : action.badge }}
|
||||
{{ action.badgeLabel || (action.badge! > 99 ? '99+' : action.badge) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="duser-actions__label">{{ action.label }}</span>
|
||||
|
||||
@@ -81,10 +81,10 @@
|
||||
/>
|
||||
<BasicIcon v-else :icon="action.icon" />
|
||||
<span
|
||||
v-if="action.badge && action.badge > 0"
|
||||
v-if="action.badgeLabel || (action.badge && action.badge > 0)"
|
||||
class="user-menu-badge"
|
||||
>
|
||||
{{ action.badge > 99 ? '99+' : action.badge }}
|
||||
{{ action.badgeLabel || (action.badge! > 99 ? '99+' : action.badge) }}
|
||||
</span>
|
||||
</span>
|
||||
<div>{{ action.label }}</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
|
||||
<div class="tv-monitor__quote">
|
||||
<span class="tv-monitor__quote-label">مظنه لحظهای</span>
|
||||
<span class="tv-monitor__quote-label">قیمت لحظهای</span>
|
||||
<div class="tv-monitor__quote-prices">
|
||||
<div class="tv-monitor__quote-item tv-monitor__quote-item--sell">
|
||||
<span>فروش</span>
|
||||
|
||||
Reference in New Issue
Block a user