From 2048680d7a7fcd554219e3a37cac53d8227e0817 Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Wed, 21 Oct 2026 03:39:00 -0400 Subject: [PATCH] feat(home): landing-style live chart, dynamic product tabs, and price copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../abshode-domain-lookup/learned-notes.md | 9 + components.d.ts | 1 + src/api/digipayInstallment.ts | 26 + src/api/zaarboxPriceHistory.ts | 71 +++ src/app/setupRouter.ts | 11 + src/assets/css/main.less | 1 + src/assets/img/boursecu-about-icon.png | Bin 0 -> 2438 bytes src/components/TradeFeeConfirmSummary.vue | 2 +- src/layouts/config/navigation.ts | 8 +- src/models/home.ts | 2 +- src/router/index.ts | 5 + src/store/userStore.ts | 6 +- src/utils/config.ts | 2 + src/utils/persianDigits.ts | 2 +- src/utils/persianTerminology.ts | 25 + src/utils/price.ts | 6 +- src/utils/priceSocket.ts | 2 +- src/utils/storeHours.ts | 11 +- .../base/home/components/CurrentPrice.vue | 2 +- .../home/components/LandingLiveChartMain.vue | 162 +++++ .../base/home/composables/useHomePageData.ts | 6 +- .../home/composables/useLandingLiveChart.ts | 555 ++++++++++++++++++ .../desktop/components/DesktopChartPanel.vue | 25 +- .../components/DesktopChartSection.vue | 25 +- .../desktop/components/DesktopLivePrice.vue | 2 +- .../layouts/sections/PriceChartSection.vue | 24 +- .../home/styles/landing-live-chart-main.less | 316 ++++++++++ .../base/installment/InstallmentPanel.vue | 234 ++++++++ src/views/base/installment/index.vue | 18 + .../base/user/composables/useUserPage.ts | 20 + .../base/user/desktop/DesktopUserView.vue | 4 +- src/views/base/user/mobile/MobileUserView.vue | 4 +- src/views/tv/index.vue | 2 +- 33 files changed, 1498 insertions(+), 91 deletions(-) create mode 100644 src/api/digipayInstallment.ts create mode 100644 src/api/zaarboxPriceHistory.ts create mode 100644 src/assets/img/boursecu-about-icon.png create mode 100644 src/utils/persianTerminology.ts create mode 100644 src/views/base/home/components/LandingLiveChartMain.vue create mode 100644 src/views/base/home/composables/useLandingLiveChart.ts create mode 100644 src/views/base/home/styles/landing-live-chart-main.less create mode 100644 src/views/base/installment/InstallmentPanel.vue create mode 100644 src/views/base/installment/index.vue diff --git a/.cursor/skills/abshode-domain-lookup/learned-notes.md b/.cursor/skills/abshode-domain-lookup/learned-notes.md index 2f690f6..bf4c7ea 100644 --- a/.cursor/skills/abshode-domain-lookup/learned-notes.md +++ b/.cursor/skills/abshode-domain-lookup/learned-notes.md @@ -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` همان محصول diff --git a/components.d.ts b/components.d.ts index d4a22b5..471e08b 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/src/api/digipayInstallment.ts b/src/api/digipayInstallment.ts new file mode 100644 index 0000000..4c42f6e --- /dev/null +++ b/src/api/digipayInstallment.ts @@ -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('/customer/digipay_installment') + +export const startDigipayInstallment = () => + useAxios.post('/customer/digipay_installment/start') + +export const buyDigipayInstallment = (plan: 4 | 12, amountToman: number) => + useAxios.post('/customer/digipay_installment/buy', { plan, amount_toman: amountToman }) diff --git a/src/api/zaarboxPriceHistory.ts b/src/api/zaarboxPriceHistory.ts new file mode 100644 index 0000000..281d135 --- /dev/null +++ b/src/api/zaarboxPriceHistory.ts @@ -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 = { + 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 { + 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) +} diff --git a/src/app/setupRouter.ts b/src/app/setupRouter.ts index 7c04976..ba20c09 100644 --- a/src/app/setupRouter.ts +++ b/src/app/setupRouter.ts @@ -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() diff --git a/src/assets/css/main.less b/src/assets/css/main.less index 1a737a9..4ed0e00 100644 --- a/src/assets/css/main.less +++ b/src/assets/css/main.less @@ -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'; diff --git a/src/assets/img/boursecu-about-icon.png b/src/assets/img/boursecu-about-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..df683d1f478fbd004080a04776f5251fe00dfce6 GIT binary patch literal 2438 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
-
مظنه هر {{ weightUnit }}
+
قیمت هر {{ weightUnit }}
{{ formattedUnitPrice }} ریال diff --git a/src/layouts/config/navigation.ts b/src/layouts/config/navigation.ts index b8fb80c..9e142ed 100644 --- a/src/layouts/config/navigation.ts +++ b/src/layouts/config/navigation.ts @@ -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', diff --git a/src/models/home.ts b/src/models/home.ts index 1a215ee..65eba6a 100644 --- a/src/models/home.ts +++ b/src/models/home.ts @@ -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 } \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index e0bbc21..f8c5339 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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', diff --git a/src/store/userStore.ts b/src/store/userStore.ts index 28a851e..58cbd7a 100644 --- a/src/store/userStore.ts +++ b/src/store/userStore.ts @@ -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() { diff --git a/src/utils/config.ts b/src/utils/config.ts index 5177e54..7a96dc6 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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, } diff --git a/src/utils/persianDigits.ts b/src/utils/persianDigits.ts index e29807f..8a1c86f 100644 --- a/src/utils/persianDigits.ts +++ b/src/utils/persianDigits.ts @@ -62,7 +62,7 @@ export function shouldNormalizeInputDigits(_options?: { type?: string inputmode?: string }): boolean { - // همیشه ارقام فارسی/عربی → انگلیسی (لاگین، گرم، تعداد، مظنه، …) + // همیشه ارقام فارسی/عربی → انگلیسی (لاگین، گرم، تعداد، قیمت، …) return true } diff --git a/src/utils/persianTerminology.ts b/src/utils/persianTerminology.ts new file mode 100644 index 0000000..1e4c7dc --- /dev/null +++ b/src/utils/persianTerminology.ts @@ -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) +} diff --git a/src/utils/price.ts b/src/utils/price.ts index 606d089..5347ebd 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -18,11 +18,11 @@ let lastSubscribers = 0 /** @deprecated از lastPrice استفاده کنید — برای سازگاری با کد قدیمی sync می‌شود */ export const price = ref() export const lastPrice = ref() -/** حداقل یک بار تلاش برای گرفتن مظنه تمام شده (موفق یا ناموفق) */ +/** حداقل یک بار تلاش برای گرفتن قیمت تمام شده (موفق یا ناموفق) */ export const lastPriceReady = ref(false) /** * بعد از اولین پاسخ خالی/no_quote، چند لحظه صبر می‌کنیم تا تیک SSE/زنده برسد - * و «مظنه در دسترس نیست» زودهنگام نشان داده نشود. + * و «قیمت در دسترس نیست» زودهنگام نشان داده نشود. */ export const lastPriceSettleGeneration = ref(0) export const olderPrice = ref(0) @@ -92,7 +92,7 @@ function markLastPriceReady() { scheduleQuoteAbsenceSettle() } -/** قیمت آمده، بسته قطعی است، یا پنجرهٔ انتظارِ مظنه خالی تمام شده */ +/** قیمت آمده، بسته قطعی است، یا پنجرهٔ انتظارِ قیمت خالی تمام شده */ export function isLastPriceAbsenceSettled(): boolean { void lastPriceSettleGeneration.value if (!lastPriceReady.value) return false diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index a48fb89..370f8de 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -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 diff --git a/src/utils/storeHours.ts b/src/utils/storeHours.ts index 2ab58c9..8f7351d 100644 --- a/src/utils/storeHours.ts +++ b/src/utils/storeHours.ts @@ -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() diff --git a/src/views/base/home/components/CurrentPrice.vue b/src/views/base/home/components/CurrentPrice.vue index cf51e41..ffee67d 100644 --- a/src/views/base/home/components/CurrentPrice.vue +++ b/src/views/base/home/components/CurrentPrice.vue @@ -85,7 +85,7 @@ const statusHint = computed(() => { if (displayState.value === 'closed') { return getStoreClosedHint(userStore.getValueByKey('closed_message')) } - return 'در حال حاضر مظنه‌ای برای نمایش وجود ندارد.' + return 'در حال حاضر قیمتی برای نمایش وجود ندارد.' }) const containerClass = computed(() => { diff --git a/src/views/base/home/components/LandingLiveChartMain.vue b/src/views/base/home/components/LandingLiveChartMain.vue new file mode 100644 index 0000000..564a1b8 --- /dev/null +++ b/src/views/base/home/components/LandingLiveChartMain.vue @@ -0,0 +1,162 @@ + + + diff --git a/src/views/base/home/composables/useHomePageData.ts b/src/views/base/home/composables/useHomePageData.ts index 0e68764..5bb81b2 100644 --- a/src/views/base/home/composables/useHomePageData.ts +++ b/src/views/base/home/composables/useHomePageData.ts @@ -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() } diff --git a/src/views/base/home/composables/useLandingLiveChart.ts b/src/views/base/home/composables/useLandingLiveChart.ts new file mode 100644 index 0000000..36fc3e0 --- /dev/null +++ b/src/views/base/home/composables/useLandingLiveChart.ts @@ -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(null) +const catalogProducts = getProductsCatalogRef() +const liveChartRange = ref('DAILY') +const dailyRawPoints = ref([]) +const liveRangeRawPoints = ref([]) +const liveChartLoading = ref(false) +const liveChartHover = ref(null) +const liveChartDrawn = ref(false) +const liveChartPlotEl = ref(null) + +let pricePollTimer: ReturnType | undefined +let liveChartObserver: IntersectionObserver | undefined +let initialized = false +let initPromise: Promise | 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(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('/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, + } +} diff --git a/src/views/base/home/desktop/components/DesktopChartPanel.vue b/src/views/base/home/desktop/components/DesktopChartPanel.vue index a41c1d6..24c5885 100644 --- a/src/views/base/home/desktop/components/DesktopChartPanel.vue +++ b/src/views/base/home/desktop/components/DesktopChartPanel.vue @@ -1,31 +1,10 @@ diff --git a/src/views/base/home/desktop/components/DesktopChartSection.vue b/src/views/base/home/desktop/components/DesktopChartSection.vue index 8b1edeb..d51d377 100644 --- a/src/views/base/home/desktop/components/DesktopChartSection.vue +++ b/src/views/base/home/desktop/components/DesktopChartSection.vue @@ -1,7 +1,5 @@ diff --git a/src/views/base/home/desktop/components/DesktopLivePrice.vue b/src/views/base/home/desktop/components/DesktopLivePrice.vue index 0cb3bb0..9a34968 100644 --- a/src/views/base/home/desktop/components/DesktopLivePrice.vue +++ b/src/views/base/home/desktop/components/DesktopLivePrice.vue @@ -84,7 +84,7 @@ const statusHint = computed(() => { if (displayState.value === 'closed') { return getStoreClosedHint(userStore.getValueByKey('closed_message')) } - return 'در حال حاضر مظنه‌ای برای نمایش وجود ندارد.' + return 'در حال حاضر قیمتی برای نمایش وجود ندارد.' }) const displayGoldVal = computed(() => { diff --git a/src/views/base/home/layouts/sections/PriceChartSection.vue b/src/views/base/home/layouts/sections/PriceChartSection.vue index 6b1a07b..bb8b236 100644 --- a/src/views/base/home/layouts/sections/PriceChartSection.vue +++ b/src/views/base/home/layouts/sections/PriceChartSection.vue @@ -1,27 +1,7 @@ diff --git a/src/views/base/home/styles/landing-live-chart-main.less b/src/views/base/home/styles/landing-live-chart-main.less new file mode 100644 index 0000000..e028d24 --- /dev/null +++ b/src/views/base/home/styles/landing-live-chart-main.less @@ -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; + } + } +} diff --git a/src/views/base/installment/InstallmentPanel.vue b/src/views/base/installment/InstallmentPanel.vue new file mode 100644 index 0000000..4a258b4 --- /dev/null +++ b/src/views/base/installment/InstallmentPanel.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/src/views/base/installment/index.vue b/src/views/base/installment/index.vue new file mode 100644 index 0000000..5ead747 --- /dev/null +++ b/src/views/base/installment/index.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/src/views/base/user/composables/useUserPage.ts b/src/views/base/user/composables/useUserPage.ts index 14d26f2..edbe0ba 100644 --- a/src/views/base/user/composables/useUserPage.ts +++ b/src/views/base/user/composables/useUserPage.ts @@ -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: 'ثبت کارت هدیه', diff --git a/src/views/base/user/desktop/DesktopUserView.vue b/src/views/base/user/desktop/DesktopUserView.vue index f5b6708..424628e 100644 --- a/src/views/base/user/desktop/DesktopUserView.vue +++ b/src/views/base/user/desktop/DesktopUserView.vue @@ -82,10 +82,10 @@ /> - {{ action.badge > 99 ? '99+' : action.badge }} + {{ action.badgeLabel || (action.badge! > 99 ? '99+' : action.badge) }} {{ action.label }} diff --git a/src/views/base/user/mobile/MobileUserView.vue b/src/views/base/user/mobile/MobileUserView.vue index a2bc2a6..1c4725f 100644 --- a/src/views/base/user/mobile/MobileUserView.vue +++ b/src/views/base/user/mobile/MobileUserView.vue @@ -81,10 +81,10 @@ /> - {{ action.badge > 99 ? '99+' : action.badge }} + {{ action.badgeLabel || (action.badge! > 99 ? '99+' : action.badge) }}
{{ action.label }}
diff --git a/src/views/tv/index.vue b/src/views/tv/index.vue index 1c8126f..b855199 100644 --- a/src/views/tv/index.vue +++ b/src/views/tv/index.vue @@ -16,7 +16,7 @@
- مظنه لحظه‌ای + قیمت لحظه‌ای
فروش