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:
@@ -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` همان محصول
|
||||
|
||||
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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 })
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function shouldNormalizeInputDigits(_options?: {
|
||||
type?: string
|
||||
inputmode?: string
|
||||
}): boolean {
|
||||
// همیشه ارقام فارسی/عربی → انگلیسی (لاگین، گرم، تعداد، مظنه، …)
|
||||
// همیشه ارقام فارسی/عربی → انگلیسی (لاگین، گرم، تعداد، قیمت، …)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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