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:
2026-10-21 03:39:00 -04:00
parent 7626453def
commit 2048680d7a
33 changed files with 1498 additions and 91 deletions
@@ -3,6 +3,12 @@
فرمت هر خط:
- YYYY-MM-DD | موضوع | مسیر فایل(ها) | نکتهٔ ۱ خطی
- 2026-10-21 | نمودار خانه = لندینگ | `LandingLiveChartMain.vue` + `useLandingLiveChart` + `zaarboxPriceHistory`؛ جایگزین `PriceChart` در PriceChartSection/DesktopChart* | همان `dlanding-live__main` SVG؛ داده از zaarbox + `/api/price/last`
- 2026-10-20 | ظاهر خرید اقساطی | `views/base/installment/InstallmentPanel.vue` | دسکتاپ کلاس‌های `dgift` مثل کارت هدیه؛ موبایل کارت `rounded-xl` و توضیح بالای فرم
- 2026-10-20 | خرید اقساطی دیجی‌پی | سرور `DigipayInstallmentService` + تنظیمات `enable_digipay_installment`/`digipay_*`؛ اپ `views/base/installment`؛ ادمین `customers/digipayInstallment` | لینک احراز در تنظیمات؛ مانده اعتبار را ادمین با موبایل می‌نویسد؛ خرید مس را به کیف gold می‌نشاند
- 2026-10-20 | پاپ‌آپ احراز عملیات | واریز/برداشت `useWalletPage.ensureIdentity`؛ هدیه `useGiftCardPage`؛ خرید/فروش دیالوگ `noCompleteVisible` در gold / copper&silver / DealProduct روی mount اگر is_legal=0 | همه هست؛ معامله با ورود صفحه نه با کلیک ثبت
- 2026-10-20 | هشدار قیمت موبایل | `useUserPage.ts` منوی `notifyTrigger` | فقط موبایل و با فلگ `has_page_price_trigger`؛ دسکتاپ همان سایدبار
- 2026-10-20 | آیکون درباره ما | `assets/img/boursecu-about-icon.png` + `getBoursecuActionIconSrc('about')` در `navigation.ts` و `useUserPage.ts` | آدمک حباب گفتار جایگزین info-circle در سایدبار و منوی امکانات بیشتر
- 2026-10-19 | قفل احراز واریز/هدیه | `useWalletPage` + `useGiftCardPage` + `assertCustomerIdentityVerified` روی withdraw و giftcard/use | is_legal=0 دیالوگ می‌دهد و API هم رد می‌کند
- 2026-10-19 | آیکون پشتیبانی/هشدار/قوانین | `assets/img/boursecu-support-icon.png` + `price-trigger` + `rules` و `navigation.ts` / `useUserPage.ts` | آدمک=پشتیبانی، زنگ دلار=هشدار قیمت سایدبار، برگه=قوانین
- 2026-09-08 | لیست کارت هدیه کاربر | `views/base/giftCard/` + `api/giftCard.ts` + سرور `GiftCardController::mine` / `GET customer/giftcard/mine` | زیر فرم: available (اختصاصی + عمومی use_once=0 تک‌کد) و history؛ تخفیف فقط نمایش/سبد نه giftcard/use
@@ -39,3 +45,6 @@
- 2026-10-17 | باز/بسته خرید فروش محصول | `utils/productTradeOpen.ts` + `gold/index.vue` | `open_buy`/`open_sell`؛ بدون ProductItem هر دو باز
- 2026-10-17 | نمودار مهمان ۴۰۱ | `usePriceChart.ts` + `api/home.ts` + سرور `routes/api.php` `POST /api/price/all` | مهمان product/list نمی‌زند؛ تاریخچه نمودار از `/api/price/all` (همان priceData)
- 2026-10-17 | قیمت زنده مهمان | `bootstrap.ts` + `priceSocket.ts` + سرور `/api/price/stream` بدون auth | SSE بدون توکن؛ polling پشتیبان product/list فقط با توکن
- 2026-10-21 | نمودار خانه = کارت لندینگ | `home/components/LandingLiveChartMain.vue` + `useLandingLiveChart.ts` + `styles/landing-live-chart-main.less` | موبایل PriceChartSection و دسکتاپ DesktopChartSection/Panel همین کارت؛ استایل از main.less
- 2026-10-21 | چیدمان کارت نمودار خانه | همان دو فایل vue/less | هدر: نام+وضعیت راست، تب محصول چپ؛ قیمت راست و تغییر چپ؛ تب بازه کنار عنوان نمودار
- 2026-10-21 | تب نمودار = محصول فعال | `useLandingLiveChart.ts` + `LandingLiveChartMain.vue` | تب از کاتالوگ `active && show`؛ داده با `price_offset_percent` همان محصول
+1
View File
@@ -14,6 +14,7 @@ declare module 'vue' {
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AModal: typeof import('ant-design-vue/es')['Modal']
AppCreditLink: typeof import('./src/components/AppCreditLink.vue')['default']
+26
View File
@@ -0,0 +1,26 @@
import useAxios from '@/utils/useAxios'
export interface DigipayInstallmentStatus {
enabled: boolean
status: 'none' | 'pending' | 'active' | string
plan4_remaining_toman: number
plan12_remaining_toman: number
gram_price_rial: number
config: {
onboarding_url: string
fee_shop_percent: number
fee_gateway_percent: number
fee_extra_percent: number
plan4_cap_toman: number
plan12_cap_toman: number
}
}
export const fetchDigipayInstallment = () =>
useAxios.get<DigipayInstallmentStatus>('/customer/digipay_installment')
export const startDigipayInstallment = () =>
useAxios.post<DigipayInstallmentStatus>('/customer/digipay_installment/start')
export const buyDigipayInstallment = (plan: 4 | 12, amountToman: number) =>
useAxios.post('/customer/digipay_installment/buy', { plan, amount_toman: amountToman })
+71
View File
@@ -0,0 +1,71 @@
/** تاریخچه قیمت عمومی بورس کالا (بدون توکن) — کاتد مس */
export const ZAANBOX_COPPER_CTHD_ID = '8c19e06a-446e-3d56-9cc0-750f7c6cd767'
export const ZAANBOX_HISTORY_URL = `https://price.zaarbox.ir/api/history/${ZAANBOX_COPPER_CTHD_ID}`
export type ZaanboxHistoryPoint = {
time: string
price: number
}
export type ZaanboxHistoryResponse = {
history?: ZaanboxHistoryPoint[]
data?: { history?: ZaanboxHistoryPoint[] }
}
export type LandingHistoryRange = 'DAILY' | 'WEEKLY' | 'MONTHLY'
export type LandingHistorySample = {
timeMs: number
price: number
}
const RANGE_MS: Record<LandingHistoryRange, number> = {
DAILY: 1 * 24 * 60 * 60 * 1000,
WEEKLY: 7 * 24 * 60 * 60 * 1000,
MONTHLY: 30 * 24 * 60 * 60 * 1000,
}
const HISTORY_LIMIT = 1_000_000_000
function resolveHistoryRows(payload: unknown): ZaanboxHistoryPoint[] {
if (!payload || typeof payload !== 'object') return []
const body = payload as ZaanboxHistoryResponse
if (Array.isArray(body.history)) return body.history
if (Array.isArray(body.data?.history)) return body.data!.history!
return []
}
export async function fetchZaanboxPriceHistory(
range: LandingHistoryRange,
now = new Date(),
): Promise<LandingHistorySample[]> {
const end = now
const start = new Date(end.getTime() - RANGE_MS[range])
const url = new URL(ZAANBOX_HISTORY_URL)
url.searchParams.set('start', start.toISOString())
url.searchParams.set('end', end.toISOString())
url.searchParams.set('limit', String(HISTORY_LIMIT))
const response = await fetch(url.toString(), {
method: 'GET',
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`Zaanbox history HTTP ${response.status}`)
}
const payload = (await response.json()) as unknown
const rows = resolveHistoryRows(payload)
return rows
.map((row) => {
const timeMs = Date.parse(String(row.time ?? ''))
const price = Number(row.price)
return { timeMs, price }
})
.filter((point) => Number.isFinite(point.timeMs) && Number.isFinite(point.price) && point.price > 0)
.sort((a, b) => a.timeMs - b.timeMs)
}
+11
View File
@@ -93,6 +93,17 @@ export function setupRouterGuards() {
}
}
if (to.name === 'installment') {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (!settingsStore.isSettingTruthy('enable_digipay_installment')) {
return next({ name: (from.name as string) || 'user' })
}
}
if (to.name === 'giftCard') {
const { useSettingsStore } = await import('@/store/settingsStore')
const settingsStore = useSettingsStore()
+1
View File
@@ -16,6 +16,7 @@
@import (less) '../../views/base/rules/desktop/styles/desktop-rules.less';
@import (less) '../../views/base/rules/permissions/desktop/styles/desktop-permissions.less';
@import (less) '../../views/base/giftCard/desktop/styles/desktop-gift-card.less';
@import (less) '../../views/base/home/styles/landing-live-chart-main.less';
@import (less) '../../views/base/address/desktop/styles/desktop-address.less';
@import (less) '../../views/base/educate/desktop/styles/desktop-educate.less';
@import (less) '../../views/base/aboutUs/desktop/styles/desktop-about-us.less';
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="trade-fee-summary">
<div class="trade-fee-summary__quote">
<div class="trade-fee-summary__label">مظنه هر {{ weightUnit }}</div>
<div class="trade-fee-summary__label">قیمت هر {{ weightUnit }}</div>
<div class="trade-fee-summary__value trade-fee-summary__value--strong">
<span>{{ formattedUnitPrice }}</span>
<span class="trade-fee-summary__unit">ریال</span>
+7 -1
View File
@@ -64,7 +64,13 @@ export const SECONDARY_NAV: AppNavItem[] = [
icon: 'vuesax-linear:bill',
iconSrc: getBoursecuActionIconSrc('priceTrigger'),
},
{ id: 'about', routeName: 'about', label: 'درباره ما', icon: 'vuesax-linear:info-circle' },
{
id: 'about',
routeName: 'about',
label: 'درباره ما',
icon: 'vuesax-linear:info-circle',
iconSrc: getBoursecuActionIconSrc('about'),
},
{
id: 'rules',
routeName: 'rules',
+1 -1
View File
@@ -32,7 +32,7 @@ export interface Price {
main_product?: number
montzar?: number
zaarbox?: number
/** montzar | zaarbox | main_product | store = مظنه زنده، database_last = آخرین قیمت ذخیره‌شده */
/** montzar | zaarbox | main_product | store = قیمت زنده، database_last = آخرین قیمت ذخیره‌شده */
price_source?: string | null
last_historical?: number
}
+5
View File
@@ -128,6 +128,11 @@ const router = createRouter({
name: 'giftCard',
component: () => import('@/views/base/giftCard/index.vue')
},
{
path: 'installment',
name: 'installment',
component: () => import('@/views/base/installment/index.vue')
},
{
path: 'recoverySites',
name: 'recoverySites',
+5 -1
View File
@@ -7,6 +7,7 @@ import { useWalletStore } from '@/store/walletStore'
import { useSettingsStore } from '@/store/settingsStore'
import { setFingerPrint } from '@/utils'
import type { PWallet } from '@/models/wallet'
import { replaceMazanehTerminology } from '@/utils/persianTerminology'
export const useUserStore = defineStore('user', () => {
@@ -32,7 +33,10 @@ export const useUserStore = defineStore('user', () => {
const item = userInfo.value?.settings?.find(obj => obj.key === keyName)
// اگر آیتم پیدا شد، فیلد value را برگردان، در غیر این صورت null
return item ? item.value : null
if (!item) return null
const value = item.value
if (typeof value === 'string') return replaceMazanehTerminology(value)
return value
}
function removeInformation() {
+2
View File
@@ -10,6 +10,7 @@ import boursecuAssetsIcon from '@/assets/img/boursecu-assets-icon.png'
import boursecuHomeIcon from '@/assets/img/boursecu-home-icon.png'
import boursecuSettingsIcon from '@/assets/img/boursecu-settings-icon.png'
import boursecuSupportIcon from '@/assets/img/boursecu-support-icon.png'
import boursecuAboutIcon from '@/assets/img/boursecu-about-icon.png'
import boursecuPriceTriggerIcon from '@/assets/img/boursecu-price-trigger-icon.png'
import boursecuRulesIcon from '@/assets/img/boursecu-rules-icon.png'
@@ -81,6 +82,7 @@ export function getBoursecuActionIconSrc(actionId: string): string | undefined {
home: boursecuHomeIcon,
settings: boursecuSettingsIcon,
support: boursecuSupportIcon,
about: boursecuAboutIcon,
priceTrigger: boursecuPriceTriggerIcon,
rules: boursecuRulesIcon,
}
+1 -1
View File
@@ -62,7 +62,7 @@ export function shouldNormalizeInputDigits(_options?: {
type?: string
inputmode?: string
}): boolean {
// همیشه ارقام فارسی/عربی → انگلیسی (لاگین، گرم، تعداد، مظنه، …)
// همیشه ارقام فارسی/عربی → انگلیسی (لاگین، گرم، تعداد، قیمت، …)
return true
}
+25
View File
@@ -0,0 +1,25 @@
/** جایگزینی «مظنه» و شکل‌های وابسته با «قیمت» در متن‌های نمایشی به مشتری */
const MAZANEH_TO_PRICE: Array<[RegExp, string]> = [
[/مظنه‌های/g, 'قیمت‌های'],
[/مظنه‌ها/g, 'قیمت‌ها'],
[/مظنه‌ای/g, 'قیمتی'],
[/مظنهای/g, 'قیمتی'],
[/مظنهٔ/g, 'قیمت'],
[/مظنه/g, 'قیمت'],
]
export function replaceMazanehTerminology(text: string): string {
let result = text
for (const [pattern, replacement] of MAZANEH_TO_PRICE) {
result = result.replace(pattern, replacement)
}
return result
}
export function polishUserFacingCopy(text: unknown): string {
if (typeof text !== 'string') return ''
const trimmed = text.trim()
if (!trimmed) return ''
return replaceMazanehTerminology(trimmed)
}
+3 -3
View File
@@ -18,11 +18,11 @@ let lastSubscribers = 0
/** @deprecated از lastPrice استفاده کنید — برای سازگاری با کد قدیمی sync می‌شود */
export const price = ref<Price>()
export const lastPrice = ref<Price>()
/** حداقل یک بار تلاش برای گرفتن مظنه تمام شده (موفق یا ناموفق) */
/** حداقل یک بار تلاش برای گرفتن قیمت تمام شده (موفق یا ناموفق) */
export const lastPriceReady = ref(false)
/**
* بعد از اولین پاسخ خالی/no_quote، چند لحظه صبر می‌کنیم تا تیک SSE/زنده برسد
* و «مظنه در دسترس نیست» زودهنگام نشان داده نشود.
* و «قیمت در دسترس نیست» زودهنگام نشان داده نشود.
*/
export const lastPriceSettleGeneration = ref(0)
export const olderPrice = ref<number | undefined>(0)
@@ -92,7 +92,7 @@ function markLastPriceReady() {
scheduleQuoteAbsenceSettle()
}
/** قیمت آمده، بسته قطعی است، یا پنجرهٔ انتظارِ مظنه خالی تمام شده */
/** قیمت آمده، بسته قطعی است، یا پنجرهٔ انتظارِ قیمت خالی تمام شده */
export function isLastPriceAbsenceSettled(): boolean {
void lastPriceSettleGeneration.value
if (!lastPriceReady.value) return false
+1 -1
View File
@@ -38,7 +38,7 @@ const STREAM_CONNECTING = 0
const STREAM_OPEN = 1
const STREAM_CLOSED = 2
/** fetch + ReadableStream — با توکن اختلاف قیمت ویژه؛ بدون توکن همان مظنهٔ عمومی (مهمان). */
/** fetch + ReadableStream — با توکن اختلاف قیمت ویژه؛ بدون توکن همان قیمت عمومی (مهمان). */
class AuthPriceStream extends EventTarget {
readyState = STREAM_CONNECTING
onopen: (() => void) | null = null
+6 -5
View File
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { useSettingsStore } from '@/store/settingsStore'
import { isLastPriceAbsenceSettled, lastPrice, lastPriceReady } from '@/utils/price'
import { replaceMazanehTerminology } from '@/utils/persianTerminology'
export interface StoreTradingHours {
start: string
@@ -253,7 +254,7 @@ export function isStoreClosed(): boolean {
export const STORE_CLOSED_TITLE = 'فروشگاه بسته است'
export function stripHtmlMessage(message: string): string {
return message.replace(/<[^>]*>/g, '').trim()
return replaceMazanehTerminology(message.replace(/<[^>]*>/g, '').trim())
}
export function getStoreClosedHint(customMessage?: unknown): string {
@@ -283,7 +284,7 @@ export function shouldBlockStoreTrading(): boolean {
export type PriceDisplayState = 'open' | 'closed' | 'no_quote'
export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
export const NO_QUOTE_MESSAGE = 'قیمت در دسترس نیست'
/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */
export function shouldShowHomePriceWhenClosed(): boolean {
@@ -294,7 +295,7 @@ export function shouldShowLastSavedPrice(): boolean {
return useSettingsStore().isSettingTruthy('show_last_price')
}
/** فقط این منابع «آخرین قیمت ذخیره‌شده» هستند؛ باقی price_source مظنهٔ زنده است */
/** فقط این منابع «آخرین قیمت ذخیره‌شده» هستند؛ باقی price_source قیمت زنده است */
const STORED_PRICE_SOURCES = ['database_last']
function hasShowableHomePrice(p = lastPrice.value): boolean {
@@ -319,7 +320,7 @@ function hasLiveQuote(p = lastPrice.value): boolean {
/**
* وضعیت نمایش قیمت در صفحه اصلی — همان منطق بک‌اند:
* خارج از ساعت کاری (close_price روشن) → show_home_price_when_closed روشن: آخرین قیمت؛ خاموش: closed
* داخل ساعت کاری → مظنه زنده: open ؛ وگرنه فقط با show_last_price آخرین قیمت؛ وگرنه no_quote
* داخل ساعت کاری → قیمت زنده: open ؛ وگرنه فقط با show_last_price آخرین قیمت؛ وگرنه no_quote
*/
export function getPriceDisplayState(): PriceDisplayState {
void productTradingHoursVersion.value
@@ -348,7 +349,7 @@ export function getPriceDisplayState(): PriceDisplayState {
/**
* اسکلتون تا وقتی قیمت قابل‌نمایش برسد، یا وضعیت بسته قطعی شود،
* یا بعد از اولین پاسخ خالی پنجرهٔ settle تمام شود (مظنه واقعاً نیست).
* یا بعد از اولین پاسخ خالی پنجرهٔ settle تمام شود (قیمت واقعاً نیست).
*/
export function isHomeQuotePending(): boolean {
const settings = useSettingsStore()
@@ -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>
+18
View File
@@ -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>
+1 -1
View File
@@ -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>