edit button install pwa

This commit is contained in:
2026-08-09 11:07:42 +03:30
parent 06349ab208
commit fa42e96fa0
7 changed files with 1128 additions and 306 deletions
+3
View File
@@ -7,6 +7,9 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AButton: typeof import('ant-design-vue/es')['Button']
AModal: typeof import('ant-design-vue/es')['Modal']
ASpin: typeof import('ant-design-vue/es')['Spin']
BasicButton: typeof import('./src/components/BasicPacks/BasicButton.vue')['default'] BasicButton: typeof import('./src/components/BasicPacks/BasicButton.vue')['default']
BasicIcon: typeof import('./src/components/BasicPacks/BasicIcon.vue')['default'] BasicIcon: typeof import('./src/components/BasicPacks/BasicIcon.vue')['default']
BasicModal: typeof import('./src/components/BasicPacks/BasicModal.vue')['default'] BasicModal: typeof import('./src/components/BasicPacks/BasicModal.vue')['default']
+4 -1
View File
@@ -8,4 +8,7 @@ export const getPrice = async () => await useAxios.get<Price>(`/api/price/up`)
export const getLastPrice = async () => await useAxios.get<Price>(`/api/price/last`) export const getLastPrice = async () => await useAxios.get<Price>(`/api/price/last`)
export const getAllPrices = async () => await useAxios.post('/customer/prices/all') export const getAllPrices = async () =>
await useAxios.post('/customer/prices/all', undefined, {
meta: { errorMessage: 'none' },
})
+5
View File
@@ -11,10 +11,15 @@ import { loadAppConfig } from '@/config/load-config'
import { setAppConfig } from '@/config/app-config' import { setAppConfig } from '@/config/app-config'
import { applyProjectTheme } from '@/utils/config' import { applyProjectTheme } from '@/utils/config'
import { useSettingsStore } from '@/store/settingsStore' import { useSettingsStore } from '@/store/settingsStore'
import { initPwaInstallCapture, startPwaManifest } from '@/modules/pwa/manifest'
/** قبل از bootstrap — تا beforeinstallprompt از دست نرود */
initPwaInstallCapture()
async function bootstrapLanding() { async function bootstrapLanding() {
const config = await loadAppConfig() const config = await loadAppConfig()
setAppConfig(config) setAppConfig(config)
startPwaManifest(config)
applyProjectTheme() applyProjectTheme()
const app = createApp(App) const app = createApp(App)
+589
View File
@@ -0,0 +1,589 @@
import type { AppConfig } from '@/config/types'
import { getConfig, getRawAppConfig } from '@/config/app-config'
import { getConfigUrls, sortConfigUrls } from '@/config/load-config'
import {
clearCachedPwaManifest,
readCachedPwaManifest,
writeCachedPwaManifest,
type PwaManifest
} from '@/modules/pwa/manifest-cache'
export { clearCachedPwaManifest, type PwaManifest, type PwaManifestIcon } from '@/modules/pwa/manifest-cache'
export const PWA_MANIFEST_READY_KEY = 'pwa_manifest_ready'
export const PWA_MANIFEST_READY_EVENT = 'pwa-manifest-ready'
type BeforeInstallPromptEventLike = Event & {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
/** نگه‌داری رویداد beforeinstallprompt تا فراخوانی prompt() */
let deferredInstallPrompt: BeforeInstallPromptEventLike | null = null
let installCaptureInitialized = false
const MANIFEST_RETRY_DELAY_MS = 500
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function normalizeBaseUrl(url: string) {
return url.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
}
/** همان baseای که get_user از آن لود شده (کش شده در localStorage) */
export function getConfigBaseFromStorage() {
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
if (!lastSuccess) return ''
return normalizeBaseUrl(lastSuccess)
}
function configUrlToManifestUrl(configUrl: string) {
return `${configUrl.replace(/\/+$/, '')}/manifest.json`
}
/** همان ۵ URL پایهٔ get_user — اولویت با CONFIG_Success_URL */
export function resolveManifestUrls(config?: AppConfig | null): string[] {
const explicit = getConfig<string>('manifest_url')
if (explicit) return [explicit]
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
}
/** اولین کاندید — برای سازگاری */
export function resolveManifestUrl(config?: AppConfig | null) {
return resolveManifestUrls(config)[0] ?? ''
}
function isValidManifest(payload: unknown): payload is PwaManifest {
if (!payload || typeof payload !== 'object') return false
const manifest = payload as PwaManifest
const icons = manifest.icons
if (!Array.isArray(icons) || icons.length === 0) return false
return icons.some((icon) => typeof icon?.src === 'string' && icon.src.trim().length > 0)
}
async function fetchManifestOnce(url: string, timeout = 6000): Promise<PwaManifest> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/manifest+json, application/json' },
cache: 'default',
signal: controller.signal,
priority: 'high'
})
if (!response.ok) {
throw new Error(`Manifest request failed: ${response.status}`)
}
const payload = await response.json()
if (!isValidManifest(payload)) {
throw new Error('Manifest response is missing valid icons')
}
return payload
} finally {
clearTimeout(timeoutId)
}
}
/** هر URL یک بار — مثل لوپ get_user */
async function fetchManifestTryingUrls(
urls: string[]
): Promise<{ manifest: PwaManifest; manifestUrl: string }> {
if (!urls.length) {
throw new Error('No manifest URL candidates')
}
let lastError: unknown = null
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
try {
const manifest = await fetchManifestOnce(url)
return { manifest, manifestUrl: url }
} catch (error) {
lastError = error
console.warn(`PWA manifest failed (${url}), candidate ${index + 1}/${urls.length}`, error)
}
if (index < urls.length - 1) {
await sleep(MANIFEST_RETRY_DELAY_MS)
}
}
throw lastError ?? new Error('PWA manifest could not be loaded from any server')
}
let manifestBlobUrl: string | null = null
function revokeManifestBlobUrl() {
if (manifestBlobUrl) {
URL.revokeObjectURL(manifestBlobUrl)
manifestBlobUrl = null
}
}
function isAbsoluteWebUrl(src: string) {
return /^https?:\/\//i.test(src) || src.startsWith('//')
}
function toAbsoluteAssetUrl(src: string, base: string) {
const value = String(src || '').trim()
if (!value) return ''
if (value.startsWith('//')) {
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'
return `${protocol}${value}`
}
if (/^(https?:\/\/|data:|blob:)/i.test(value)) return value
if (!base) return value
return `${base.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}`
}
/** path داخل همین اپ — دامنهٔ سرور (montpar.ir) نادیده گرفته می‌شود */
function toManifestPath(url: string | undefined, fallback = '/') {
const defaultPath = fallback.startsWith('/') ? fallback : `/${fallback}`
if (!url?.trim()) return defaultPath
try {
const trimmed = url.trim()
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
return trimmed
}
const parsed = new URL(trimmed)
const path = `${parsed.pathname || '/'}${parsed.search}${parsed.hash}`
if (parsed.origin !== window.location.origin) {
return path || '/'
}
return path || '/'
} catch {
return defaultPath
}
}
/**
* blob manifest فقط URL مطلق هم‌origin را می‌پذیرد (مثلاً http://192.168.x.x:3100/).
* مسیر نسبی مثل "/" → "property ignored, URL is invalid".
*/
function toSameOriginManifestUrl(url: string | undefined, fallbackPath = '/') {
const path = toManifestPath(url, fallbackPath)
return new URL(path, window.location.origin).href
}
function normalizeManifestId(id: string | undefined, startUrl: string) {
const value = String(id || '').trim()
if (!value || value === '/') return startUrl
try {
const parsed = new URL(value, window.location.origin)
if (parsed.origin === window.location.origin) return parsed.href
} catch {
// ignore
}
return startUrl
}
function guessMimeType(src: string) {
const lower = src.split('?')[0].toLowerCase()
if (lower.endsWith('.png')) return 'image/png'
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
if (lower.endsWith('.webp')) return 'image/webp'
return 'image/png'
}
function mapManifestIcons(manifest: PwaManifest, base: string) {
return (manifest.icons || [])
.map((icon) => {
const src = toAbsoluteAssetUrl(icon.src, base)
return {
src,
sizes: icon.sizes || '512x512',
type: guessMimeType(src),
purpose: icon.purpose || 'any'
}
})
.filter((icon) => isAbsoluteWebUrl(icon.src))
}
/** فقط آیکون‌های داخل monifist.json — بدون آواتار / pwa-icons / env */
function resolveInstallIcons(manifest: PwaManifest) {
const base = getConfigBaseFromStorage()
const mapped = mapManifestIcons(manifest, base)
if (!mapped.length) {
throw new Error('Manifest icons could not be resolved to absolute URLs')
}
return mapped
}
function prepareManifestForInstall(manifest: PwaManifest) {
const scopeUrl = toSameOriginManifestUrl(manifest.scope ?? manifest.start_url, '/')
const startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
const icons = resolveInstallIcons(manifest)
const name = manifest.short_name || manifest.name
if (!name) {
throw new Error('Manifest is missing name/short_name')
}
if (!manifest.theme_color) {
throw new Error('Manifest is missing theme_color')
}
// start_url باید داخل scope باشد
const scopeBase = scopeUrl.endsWith('/') ? scopeUrl : `${scopeUrl}/`
const effectiveScope = startUrl.startsWith(scopeBase) ? scopeUrl : toSameOriginManifestUrl('/', '/')
return {
id: normalizeManifestId(manifest.id, startUrl),
name,
short_name: manifest.short_name || manifest.name || name,
lang: manifest.lang || 'fa',
dir: manifest.dir || 'rtl',
start_url: startUrl,
scope: effectiveScope,
display: manifest.display || 'standalone',
background_color: manifest.background_color || '#ffffff',
theme_color: manifest.theme_color,
description: manifest.description || name,
icons
}
}
function pickManifestIconUrl(manifest: PwaManifest, preferredSizes: number[]) {
const icons = manifest.icons ?? []
for (const size of preferredSizes) {
const target = `${size}x${size}`
const match = icons.find((icon) => String(icon.sizes || '').includes(target))
if (match?.src) return match.src
}
const sorted = [...icons].sort((a, b) => {
const aSize = Number.parseInt(String(a.sizes || '0'), 10) || 0
const bSize = Number.parseInt(String(b.sizes || '0'), 10) || 0
return bSize - aSize
})
return sorted[0]?.src || ''
}
function upsertLink(rel: string, attrs: Record<string, string>) {
const selectorParts = Object.entries(attrs)
.filter(([key]) => key !== 'href')
.map(([key, value]) => `[${key}="${value}"]`)
.join('')
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]${selectorParts}`)
if (!link) {
link = document.createElement('link')
link.rel = rel
document.head.appendChild(link)
}
Object.entries(attrs).forEach(([key, value]) => {
link!.setAttribute(key, value)
})
return link
}
function upsertMeta(name: string, content: string) {
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
if (!meta) {
meta = document.createElement('meta')
meta.name = name
document.head.appendChild(meta)
}
meta.content = content
}
function removeAllManifestLinks() {
document.querySelectorAll<HTMLLinkElement>("link[rel='manifest']").forEach((node) => node.remove())
}
function removeDynamicManifestLinks() {
removeAllManifestLinks()
revokeManifestBlobUrl()
document
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
.forEach((node) => node.remove())
}
function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
document
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
.forEach((node) => node.remove())
const appleSizes = [180, 167, 152, 120]
const used = new Set<string>()
appleSizes.forEach((size) => {
const href = pickManifestIconUrl(manifest, [size, 192, 512])
if (!href || used.has(href)) return
used.add(href)
upsertLink('apple-touch-icon', {
'data-dynamic-pwa': 'true',
sizes: `${size}x${size}`,
href
})
})
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
if (primary) {
upsertLink('apple-touch-icon', {
'data-dynamic-pwa': 'true',
href: primary
})
}
}
export async function applyPreparedManifestToDocument(
normalized: PwaManifest,
manifestUrl: string
) {
removeDynamicManifestLinks()
if (!normalized.icons?.length) {
throw new Error('Manifest has no valid absolute icon URLs')
}
const title = normalized.short_name || normalized.name
if (!title) {
throw new Error('Prepared manifest is missing title')
}
document.title = title
const blob = new Blob([JSON.stringify(normalized)], {
type: 'application/manifest+json'
})
manifestBlobUrl = URL.createObjectURL(blob)
upsertLink('manifest', {
'data-dynamic-pwa': 'true',
href: manifestBlobUrl
})
if (normalized.theme_color) {
upsertMeta('theme-color', normalized.theme_color)
}
upsertMeta('apple-mobile-web-app-title', title)
upsertMeta('mobile-web-app-capable', 'yes')
upsertMeta('apple-mobile-web-app-capable', 'yes')
upsertMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')
applyAppleTouchIconsFromManifest(normalized)
const favicon = pickManifestIconUrl(normalized, [192, 180, 128, 96, 72])
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
if (favicon) {
upsertLink('icon', { type: 'image/png', href: favicon })
}
}
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
const prepared = prepareManifestForInstall(manifest)
await applyPreparedManifestToDocument(prepared, manifestUrl)
}
export function setPwaManifestReady(enabled: boolean) {
if (enabled) {
sessionStorage.setItem(PWA_MANIFEST_READY_KEY, '1')
} else {
sessionStorage.removeItem(PWA_MANIFEST_READY_KEY)
}
}
export function isPwaManifestReady() {
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
}
export function getDeferredPwaInstallPrompt() {
return deferredInstallPrompt
}
/** اگر deferred prompt موجود باشد، دیالوگ نصب مرورگر را نشان می‌دهد */
export async function promptDeferredPwaInstall(): Promise<boolean> {
const event = deferredInstallPrompt
if (!event || typeof event.prompt !== 'function') return false
deferredInstallPrompt = null
try {
await event.prompt()
await event.userChoice.catch(() => undefined)
return true
} catch {
return false
}
}
/** هرچه زودتر در main.ts — قبل از bootstrap */
export function initPwaInstallCapture() {
if (installCaptureInitialized || typeof window === 'undefined') return
installCaptureInitialized = true
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredInstallPrompt = e as BeforeInstallPromptEventLike
if (isPwaManifestReady()) {
notifyPwaManifestReady(e)
return
}
console.info('PWA install prompt captured, waiting for manifest + service worker')
})
}
export function notifyPwaManifestReady(installEvent?: Event | null) {
if (!isPwaManifestReady()) return
if (installEvent) {
deferredInstallPrompt = installEvent as BeforeInstallPromptEventLike
}
const event = installEvent ?? deferredInstallPrompt
window.dispatchEvent(
new CustomEvent(PWA_MANIFEST_READY_EVENT, {
detail: { installEvent: event }
})
)
if (!event) {
console.info('PWA ready (manifest + SW). Waiting for beforeinstallprompt…', {
secureContext: window.isSecureContext,
origin: window.location.origin
})
}
}
async function applyCachedManifestEntry(
cached: ReturnType<typeof readCachedPwaManifest>
): Promise<boolean> {
if (!cached) return false
try {
const prepared = prepareManifestForInstall(cached.manifest)
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
setPwaManifestReady(true)
return true
} catch (error) {
console.warn('Cached PWA manifest invalid, refetching:', error)
clearCachedPwaManifest()
return false
}
}
/**
* بعد از loadAppConfig / setAppConfig صدا بزن.
* get_user دوباره fetch نمی‌شود — manifest از همان ۵ URL پایهٔ get_user امتحان می‌شود.
*/
export async function setupPwaManifest(config?: AppConfig | null): Promise<boolean> {
const raw = config ?? getRawAppConfig()
const userId = raw?.user?.id
const configBase = getConfigBaseFromStorage()
const manifestUrls = resolveManifestUrls(raw)
if (!manifestUrls.length) {
console.warn('PWA manifest skipped: no manifest URL candidates')
removeDynamicManifestLinks()
setPwaManifestReady(false)
return false
}
const cached = configBase ? readCachedPwaManifest(userId, configBase) : null
if (cached && (await applyCachedManifestEntry(cached))) {
console.info('PWA manifest applied from cache:', cached.manifest_url)
return true
}
try {
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
const prepared = prepareManifestForInstall(manifest)
await applyPreparedManifestToDocument(prepared, manifestUrl)
setPwaManifestReady(true)
if (userId != null && userId !== '') {
const effectiveConfigBase =
configBase || normalizeBaseUrl(manifestUrl.replace(/\/manifest\.json$/i, ''))
writeCachedPwaManifest({
cached_at: Date.now(),
user_id: userId,
config_base: effectiveConfigBase,
manifest_url: manifestUrl,
manifest: prepared
})
}
console.info('PWA manifest loaded once:', manifestUrl)
return true
} catch (error) {
console.warn('PWA manifest unavailable, PWA disabled this session:', error)
removeDynamicManifestLinks()
setPwaManifestReady(false)
return false
}
}
let manifestBootstrapStarted = false
/**
* بلافاصله بعد از setAppConfig — بدون await در bootstrap.
* اپ منتظر مانیفست نمی‌ماند؛ SW بعد از موفقیت در پس‌زمینه ثبت می‌شود.
*/
export function startPwaManifest(config?: AppConfig | null) {
if (manifestBootstrapStarted) return
manifestBootstrapStarted = true
void setupPwaManifest(config).then((ready) => {
if (!ready) return
void import('@/modules/pwa/register-pwa').then(({ registerAppPwa }) => registerAppPwa())
})
}
+56
View File
@@ -0,0 +1,56 @@
import { isPwaManifestReady, notifyPwaManifestReady } from '@/modules/pwa/manifest'
let swRegistrationStarted = false
function isMainAppServiceWorker(scriptURL: string) {
try {
const path = new URL(scriptURL, window.location.origin).pathname
return path === '/sw.js'
} catch {
return /\/sw\.js$/i.test(scriptURL)
}
}
/**
* همان SW اپ اصلی را register می‌کند — بدون vite-plugin-pwa و بدون SW جدید.
* اگر registration موجود برای /sw.js با scope / باشد، دوباره اسکریپت تازه نمی‌سازد.
*/
export function registerAppPwa() {
if (!isPwaManifestReady()) {
console.info('Skipping service worker registration: manifest not ready')
return
}
if (swRegistrationStarted) return
swRegistrationStarted = true
if (!('serviceWorker' in navigator)) {
console.warn('Service workers not supported in this browser')
notifyPwaManifestReady()
return
}
void (async () => {
try {
const existing = await navigator.serviceWorker.getRegistration('/')
const existingScript =
existing?.active?.scriptURL ||
existing?.waiting?.scriptURL ||
existing?.installing?.scriptURL ||
''
if (existing && existingScript && isMainAppServiceWorker(existingScript)) {
console.info('PWA service worker already registered:', existing.scope)
notifyPwaManifestReady()
return
}
const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
console.info('PWA service worker registered:', registration.scope)
notifyPwaManifestReady()
} catch (error: unknown) {
console.warn('PWA service worker registration failed:', error)
notifyPwaManifestReady()
}
})()
}
@@ -115,10 +115,9 @@
خرید و فروش مس خرید و فروش مس
<BasicIcon icon="vuesax-linear:arrow-left" size="16" /> <BasicIcon icon="vuesax-linear:arrow-left" size="16" />
</button> </button>
<button type="button" class="dlanding-hero__secondary" @click="scrollToRules"> <button type="button" class="dlanding-hero__secondary" @click="scrollToLiveChart">
<BasicIcon icon="vuesax-linear:play" size="14" /> <BasicIcon icon="vuesax-linear:play" size="14" />
مشاهده نمودار تغییرات مشاهده نمودار تغییرات
</button> </button>
</div> </div>
@@ -146,7 +145,7 @@
role="tab" role="tab"
:aria-selected="activeProduct === 'wire'" :aria-selected="activeProduct === 'wire'"
:class="{ 'is-active': activeProduct === 'wire' }" :class="{ 'is-active': activeProduct === 'wire' }"
@click="activeProduct = 'wire'" @click="setLiveProduct('wire')"
> >
مفتول مفتول
</button> </button>
@@ -155,7 +154,7 @@
role="tab" role="tab"
:aria-selected="activeProduct === 'cathode'" :aria-selected="activeProduct === 'cathode'"
:class="{ 'is-active': activeProduct === 'cathode' }" :class="{ 'is-active': activeProduct === 'cathode' }"
@click="activeProduct = 'cathode'" @click="setLiveProduct('cathode')"
> >
کاتد کاتد
</button> </button>
@@ -185,7 +184,7 @@
<div class="dlanding-price-card__value"> <div class="dlanding-price-card__value">
<strong>{{ formattedLivePrice }}</strong> <strong>{{ formattedLivePrice }}</strong>
<span>تومان / کیلوگرم</span> <span>ریال / کیلوگرم</span>
</div> </div>
<div v-if="dayChangePercent !== 0" class="dlanding-price-card__change"> <div v-if="dayChangePercent !== 0" class="dlanding-price-card__change">
@@ -239,7 +238,7 @@
/> />
</svg> </svg>
<div v-else class="dlanding-chart__empty"> <div v-else class="dlanding-chart__empty">
{{ chartReady ? 'نمودار در دسترس نیست' : 'در حال بارگذاری نمودار…' }} {{ liveChartLoading ? 'در حال بارگذاری نمودار…' : 'نمودار در دسترس نیست' }}
</div> </div>
<div class="dlanding-chart__labels"> <div class="dlanding-chart__labels">
<span>اکنون</span> <span>اکنون</span>
@@ -260,7 +259,7 @@
</div> </div>
<label class="dlanding-order-card__label" for="dlanding-investment-input"> <label class="dlanding-order-card__label" for="dlanding-investment-input">
مبلغ سرمایه‌گذاری (تومان) مبلغ سرمایه‌گذاری (ریال)
</label> </label>
<div class="dlanding-order-card__input"> <div class="dlanding-order-card__input">
<input <input
@@ -272,7 +271,7 @@
:value="investmentDisplay" :value="investmentDisplay"
@input="onInvestmentInput" @input="onInvestmentInput"
/> />
<span>تومان</span> <span>ریال</span>
</div> </div>
<div class="dlanding-order-card__result"> <div class="dlanding-order-card__result">
@@ -321,10 +320,10 @@
<div class="dlanding-stats__divider" aria-hidden="true" /> <div class="dlanding-stats__divider" aria-hidden="true" />
<ul class="dlanding-stats__metrics"> <ul ref="statsMetricsEl" class="dlanding-stats__metrics">
<li v-for="metric in metrics" :key="metric.label" class="dlanding-stats__metric"> <li v-for="metric in displayMetrics" :key="metric.label" class="dlanding-stats__metric">
<strong class="dlanding-stats__metric-value"> <strong class="dlanding-stats__metric-value">
<span v-if="metric.prefix" class="dlanding-stats__metric-accent">{{ metric.prefix }}</span>{{ metric.value }}<span <span v-if="metric.prefix" class="dlanding-stats__metric-accent">{{ metric.prefix }}</span>{{ metric.display }}<span
v-if="metric.suffix" v-if="metric.suffix"
class="dlanding-stats__metric-accent" class="dlanding-stats__metric-accent"
>{{ metric.suffix }}</span> >{{ metric.suffix }}</span>
@@ -367,7 +366,12 @@
<p class="dlanding-appcta__text"> <p class="dlanding-appcta__text">
با اپلیکیشن بورس مس، در هر لحظه به قیمت لحظه‌ای مس دسترسی داشته باشید و معاملات خود را بدون ریسک انجام دهید. با اپلیکیشن بورس مس، در هر لحظه به قیمت لحظه‌ای مس دسترسی داشته باشید و معاملات خود را بدون ریسک انجام دهید.
</p> </p>
<button type="button" class="dlanding-appcta__btn" @click="installWebApp"> <button
type="button"
class="dlanding-appcta__btn"
:disabled="!canInstallWebApp"
@click="installWebApp"
>
<img :src="downloadIcon" alt="" class="dlanding-appcta__btn-icon" width="18" height="18" /> <img :src="downloadIcon" alt="" class="dlanding-appcta__btn-icon" width="18" height="18" />
<span>نصب وب اپلیکیشن</span> <span>نصب وب اپلیکیشن</span>
</button> </button>
@@ -417,7 +421,7 @@
</div> </div>
</section> </section>
<section class="dlanding-live" aria-labelledby="dlanding-live-title"> <section id="dlanding-live" class="dlanding-live" aria-labelledby="dlanding-live-title">
<div class="dlanding-live__inner"> <div class="dlanding-live__inner">
<header class="dlanding-live__header"> <header class="dlanding-live__header">
<h2 id="dlanding-live-title" class="dlanding-live__title">قیمت لحظه‌ای مس</h2> <h2 id="dlanding-live-title" class="dlanding-live__title">قیمت لحظه‌ای مس</h2>
@@ -429,6 +433,7 @@
<div class="dlanding-live__layout"> <div class="dlanding-live__layout">
<article class="dlanding-live__main"> <article class="dlanding-live__main">
<div class="dlanding-live__main-top"> <div class="dlanding-live__main-top">
<div class="dlanding-live__product-block">
<div class="dlanding-live__product"> <div class="dlanding-live__product">
<span <span
class="dlanding-live__product-dot" class="dlanding-live__product-dot"
@@ -438,7 +443,32 @@
}" }"
aria-hidden="true" aria-hidden="true"
/> />
<span>مفتول مس ۸ میلی‌متر</span> <span>{{ liveProductLabel }}</span>
</div>
<div
class="dlanding-live__product-tabs"
role="tablist"
aria-label="انتخاب محصول"
>
<button
type="button"
role="tab"
:aria-selected="activeProduct === 'wire'"
:class="{ 'is-active': activeProduct === 'wire' }"
@click="setLiveProduct('wire')"
>
مفتول
</button>
<button
type="button"
role="tab"
:aria-selected="activeProduct === 'cathode'"
:class="{ 'is-active': activeProduct === 'cathode' }"
@click="setLiveProduct('cathode')"
>
کاتد
</button>
</div>
</div> </div>
<div class="dlanding-live__status"> <div class="dlanding-live__status">
@@ -465,16 +495,16 @@
<div class="dlanding-live__price-block"> <div class="dlanding-live__price-block">
<div class="dlanding-live__price-row"> <div class="dlanding-live__price-row">
<strong class="dlanding-live__price">{{ formattedWireLivePrice }}</strong> <strong class="dlanding-live__price">{{ formattedLivePrice }}</strong>
<span class="dlanding-live__unit">تومان / کیلوگرم</span> <span class="dlanding-live__unit">ریال / کیلوگرم</span>
</div> </div>
<div v-if="wireDayChangePercent !== 0" class="dlanding-live__change"> <div v-if="dayChangePercent !== 0" class="dlanding-live__change">
<span <span
class="dlanding-live__change-badge" class="dlanding-live__change-badge"
:class="wireDayChangePercent > 0 ? 'is-up' : 'is-down'" :class="dayChangePercent > 0 ? 'is-up' : 'is-down'"
> >
{{ formatSignedPercentChange(wireDayChangePercent) }} {{ formatSignedPercentChange(dayChangePercent) }}
<template v-if="wireDayChangeAmountLabel"> · {{ wireDayChangeAmountLabel }}</template> <template v-if="dayChangeAmountLabel"> · {{ dayChangeAmountLabel }}</template>
</span> </span>
<span class="dlanding-live__change-label">نسبت به ابتدای روز</span> <span class="dlanding-live__change-label">نسبت به ابتدای روز</span>
</div> </div>
@@ -482,8 +512,24 @@
<div class="dlanding-live__chart-wrap"> <div class="dlanding-live__chart-wrap">
<div class="dlanding-live__chart-head"> <div class="dlanding-live__chart-head">
<span>نمودار ۲۴ ساعت گذشته</span> <span>{{ liveChartRangeLabel }}</span>
<span>تومان / کیلوگرم</span> <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>
</div> </div>
<div class="dlanding-live__chart" aria-hidden="true"> <div class="dlanding-live__chart" aria-hidden="true">
<svg <svg
@@ -520,7 +566,11 @@
/> />
</svg> </svg>
<div v-else class="dlanding-live__chart-empty"> <div v-else class="dlanding-live__chart-empty">
{{ chartReady ? 'نمودار در دسترس نیست' : 'در حال بارگذاری نمودار…' }} {{
liveChartLoading
? 'در حال بارگذاری نمودار…'
: 'نمودار در دسترس نیست'
}}
</div> </div>
</div> </div>
</div> </div>
@@ -548,7 +598,7 @@
:src="forecastTrendIcon" :src="forecastTrendIcon"
alt="" alt=""
class="dlanding-live__forecast-trend-icon" class="dlanding-live__forecast-trend-icon"
:class="{ 'is-down': wireDayChangePercent < -0.4 }" :class="{ 'is-down': dayChangePercent < -0.4 }"
width="19" width="19"
height="10" height="10"
/> />
@@ -761,7 +811,11 @@
<h3 class="dlanding-footer__col-title">{{ column.title }}</h3> <h3 class="dlanding-footer__col-title">{{ column.title }}</h3>
<ul class="dlanding-footer__links"> <ul class="dlanding-footer__links">
<li v-for="link in column.links" :key="link.label"> <li v-for="link in column.links" :key="link.label">
<a :href="link.href" class="dlanding-footer__link">{{ link.label }}</a> <a
:href="link.href"
class="dlanding-footer__link"
@click="onFooterLinkClick($event, link)"
>{{ link.label }}</a>
</li> </li>
</ul> </ul>
</div> </div>
@@ -786,13 +840,15 @@
<section id="rules" class="dlanding-anchor" aria-hidden="true"></section> <section id="rules" class="dlanding-anchor" aria-hidden="true"></section>
</div> </div>
<DesktopPwaGuideModal v-model:show="showPwaGuide" @confirm="onPwaGuideConfirm" />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { getLoginAppUrl, getLoginInstallPwaUrl } from '@/utils/config' import { getLoginAppUrl } from '@/utils/config'
import heroPattern from '@/assets/img/landing-hero-pattern.svg' import heroPattern from '@/assets/img/landing-hero-pattern.svg'
import brandLogo from '@/assets/img/landing-navbar-logo.png' import brandLogo from '@/assets/img/landing-navbar-logo.png'
import appPhonesImg from '@/assets/img/landing-app-phones.png' import appPhonesImg from '@/assets/img/landing-app-phones.png'
@@ -803,6 +859,7 @@ import type { Price } from '@/models/home'
import { persianToEnglishDigits } from '@/utils/persianDigits' import { persianToEnglishDigits } from '@/utils/persianDigits'
import { import {
applyLastPricePayload, applyLastPricePayload,
getTradeBuyPrice,
lastPrice, lastPrice,
percentPriceChanged, percentPriceChanged,
} from '@/utils/price' } from '@/utils/price'
@@ -815,8 +872,17 @@ import {
} from '@/utils/usePrice' } from '@/utils/usePrice'
import { import {
CHART_METAL_MARKUP_RATIO, CHART_METAL_MARKUP_RATIO,
resolveMetalDayRangePrices,
usePriceChart,
type ChartMetalType, type ChartMetalType,
} from '@/views/base/home/composables/usePriceChart' } from '@/views/base/home/composables/usePriceChart'
import {
PWA_MANIFEST_READY_EVENT,
getDeferredPwaInstallPrompt,
isPwaManifestReady,
promptDeferredPwaInstall,
} from '@/modules/pwa/manifest'
import DesktopPwaGuideModal from '@/views/login/desktop/components/DesktopPwaGuideModal.vue'
import { useAboutUsPageContext } from '../composables/useAboutUsPage' import { useAboutUsPageContext } from '../composables/useAboutUsPage'
const MOBILE_NAV_MQ = '(max-width: 1023.98px)' const MOBILE_NAV_MQ = '(max-width: 1023.98px)'
@@ -827,64 +893,139 @@ const LIVE_CHART_WIDTH = 560
const LIVE_CHART_HEIGHT = 160 const LIVE_CHART_HEIGHT = 160
const CHART_PAD = 10 const CHART_PAD = 10
const MAX_CHART_POINTS = 48 const MAX_CHART_POINTS = 48
const CHART_CACHE_KEY = 'dlanding_chart_series_v1'
const FALLBACK_CHART_POINTS = 24 type LiveChartRange = 'DAILY' | 'WEEKLY' | 'MONTHLY'
const LIVE_CHART_RANGE_OPTIONS: Array<{
value: LiveChartRange
shortLabel: string
label: string
}> = [
{ value: 'DAILY', shortLabel: '۲۴ ساعت', label: 'نمودار ۲۴ ساعت گذشته' },
{ value: 'WEEKLY', shortLabel: 'هفته', label: 'نمودار هفته گذشته' },
{ value: 'MONTHLY', shortLabel: 'ماه', label: 'نمودار ماه گذشته' },
]
const { config } = useAboutUsPageContext() const { config } = useAboutUsPageContext()
/** همان سیستم نمودار Home — فقط مقادیر سری، بدون UI/استایل Home */
const {
priceChartSeries,
chartSelection,
chartLoading,
updateChartData,
fetchPrices,
setChartMetal,
loadMetalChartOffsets,
} = usePriceChart()
const activeProduct = ref<'wire' | 'cathode'>('wire') const activeProduct = ref<'wire' | 'cathode'>('wire')
const liveChartRangeOptions = LIVE_CHART_RANGE_OPTIONS
const investmentAmount = ref(0) const investmentAmount = ref(0)
const rawChartValues = ref<number[]>([]) /** اسنپ‌شات DAILY برای اسپارک‌لاین هیرو وقتی بازه live عوض می‌شود */
const chartReady = ref(false) const dailyHomeChartValues = ref<number[]>([])
const openFaqIndex = ref(0) const openFaqIndex = ref(0)
const isMobileMenuOpen = ref(false) const isMobileMenuOpen = ref(false)
const showPwaGuide = ref(false)
/** Chrome deferred prompt آماده است */
const canPromptInstall = ref(false)
/** اپ از قبل نصب شده یا در حالت standalone اجرا می‌شود */
const isAppInstalledOrStandalone = ref(false)
const statsMetricsEl = ref<HTMLElement | null>(null)
const metricAnimatedValues = ref<number[]>([0, 0, 0, 0])
const metricsCountStarted = ref(false)
const projectName = computed(() => config.config?.name_fa ?? 'بورس‌مس') const projectName = computed(() => config.config?.name_fa ?? 'بورس‌مس')
const patternMaskStyle = computed(() => ({ const patternMaskStyle = computed(() => ({
WebkitMaskImage: `url(${heroPattern})`, WebkitMaskImage: `url(${heroPattern})`,
maskImage: `url(${heroPattern})`, maskImage: `url(${heroPattern})`,
})) }))
const LIVE_SECTION_METAL: ChartMetalType = 'maftol'
const orderCardSubtitle = computed(() => const orderCardSubtitle = computed(() =>
activeProduct.value === 'wire' ? 'مقدار مفتول مس قابل خرید' : 'مقدار کاتد مس قابل خرید', activeProduct.value === 'wire' ? 'مقدار مفتول مس قابل خرید' : 'مقدار کاتد مس قابل خرید',
) )
function applyMetalMarkup(basePrice: number, metal: ChartMetalType) { const liveProductLabel = computed(() =>
const ratio = CHART_METAL_MARKUP_RATIO[metal] activeProduct.value === 'wire' ? 'مفتول مس ۸ میلی‌متر' : 'کاتد مس',
)
const liveProductName = computed(() =>
activeProduct.value === 'wire' ? 'مفتول مس' : 'کاتد مس',
)
const liveChartRange = computed(() => chartSelection.value as LiveChartRange)
const liveChartRangeLabel = computed(
() =>
LIVE_CHART_RANGE_OPTIONS.find((option) => option.value === liveChartRange.value)?.label ??
'نمودار ۲۴ ساعت گذشته',
)
const liveChartLoading = chartLoading
function selectedMetalType(): ChartMetalType {
return activeProduct.value === 'wire' ? 'maftol' : 'katod'
}
/** مثل Home: کاتد +۳٪ ، مفتول +۶٪ */
function applyProductMarkup(basePrice: number) {
const ratio = CHART_METAL_MARKUP_RATIO[selectedMetalType()]
if (!ratio || !basePrice) return basePrice if (!ratio || !basePrice) return basePrice
return Math.round(basePrice + basePrice * ratio) return Math.round(basePrice + basePrice * ratio)
} }
/** پایهٔ همهٔ قیمت‌های لندینگ — فقط فیلد buy از /api/price/last */ function setLiveProduct(product: 'wire' | 'cathode') {
const baseBuyPrice = computed(() => { activeProduct.value = product
const buy = Number(lastPrice.value?.buy ?? 0) setChartMetal(product === 'wire' ? 'maftol' : 'katod')
return Number.isFinite(buy) && buy > 0 ? buy : 0
})
/** کاتد: buy خام — مفتول: buy + ۶٪ */
const liveUnitPrice = computed(() => {
if (activeProduct.value === 'cathode') {
return baseBuyPrice.value
} }
return applyMetalMarkup(baseBuyPrice.value, 'maftol')
function setLiveChartRange(range: LiveChartRange) {
updateChartData(range)
}
/** مقادیر Y همان سری Home (بعد از مارک‌آپ فلز) */
const homeChartValues = computed(() =>
(priceChartSeries.value[0]?.data ?? [])
.map((point) => Number(point?.[1]))
.filter((value) => Number.isFinite(value) && value > 0),
)
watch(
[homeChartValues, chartSelection],
([values, selection]) => {
if (selection === 'DAILY' && values.length >= 2) {
dailyHomeChartValues.value = values
}
},
{ immediate: true },
)
/** پایه قیمت — buy / price_last مثل Home */
const baseBuyPrice = computed(() => {
const buy = Number(getTradeBuyPrice(lastPrice.value) || 0)
if (Number.isFinite(buy) && buy > 0) return buy
const fromDaily = dailyHomeChartValues.value[dailyHomeChartValues.value.length - 1]
if (Number.isFinite(fromDaily) && fromDaily > 0) {
const ratio = CHART_METAL_MARKUP_RATIO[selectedMetalType()] || 0
return ratio ? Math.round(fromDaily / (1 + ratio)) : fromDaily
}
const fromLive = homeChartValues.value[homeChartValues.value.length - 1]
if (Number.isFinite(fromLive) && fromLive > 0) {
const ratio = CHART_METAL_MARKUP_RATIO[selectedMetalType()] || 0
return ratio ? Math.round(fromLive / (1 + ratio)) : fromLive
}
return 0
}) })
/** قیمت مفتول مس — همیشه برای سکشن قیمت لحظه‌ای لندینگ */ const liveUnitPrice = computed(() => applyProductMarkup(baseBuyPrice.value))
const wireLiveUnitPrice = computed(() =>
applyMetalMarkup(baseBuyPrice.value, LIVE_SECTION_METAL),
)
const formattedLivePrice = computed(() => { const formattedLivePrice = computed(() => {
if (!liveUnitPrice.value) return '—' if (!liveUnitPrice.value) return '—'
return usePriceWithoutType(liveUnitPrice.value) return usePriceWithoutType(liveUnitPrice.value)
}) })
const formattedWireLivePrice = computed(() => {
if (!wireLiveUnitPrice.value) return '—'
return usePriceWithoutType(wireLiveUnitPrice.value)
})
/** open | closed | no_quote — برای badge وضعیت بازار */ /** open | closed | no_quote — برای badge وضعیت بازار */
const liveMarketState = computed(() => { const liveMarketState = computed(() => {
const state = getPriceDisplayState() const state = getPriceDisplayState()
@@ -918,105 +1059,16 @@ const liveUpdateLabel = computed(() => {
return 'مظنه دریافت نشده' return 'مظنه دریافت نشده'
}) })
function sanitizeChartValues(values: number[]): number[] { /** اسپارک‌لاین هیرو: همیشه سری DAILY از Home */
return values.filter((value) => Number.isFinite(value) && value > 0) const chartValues = computed(() =>
} dailyHomeChartValues.value.length >= 2
? dailyHomeChartValues.value
function readCachedChartValues(): number[] { : homeChartValues.value,
try {
const raw = localStorage.getItem(CHART_CACHE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return sanitizeChartValues(parsed.map((item) => Number(item)))
} catch {
return []
}
}
function writeCachedChartValues(values: number[]) {
const cleaned = sanitizeChartValues(values)
if (cleaned.length < 2) return
try {
localStorage.setItem(CHART_CACHE_KEY, JSON.stringify(cleaned))
} catch {
/* ignore quota / private mode */
}
}
/** سری پایه از فیلد buy — برای نمایش نوسان تا آخرین مظنه خرید */
function buildFallbackChartValuesFromPrice(price = lastPrice.value): number[] {
if (!price) return []
const buy = Number(price.buy ?? 0)
if (!(Number.isFinite(buy) && buy > 0)) return []
const sell = Number(price.sell ?? 0)
const highBase = Number(price.up?.buy ?? buy)
const lowBase = Number(price.up?.sell ?? (sell > 0 ? sell : buy))
const end = buy
let lo = lowBase > 0 ? lowBase : end
let hi = highBase > 0 ? highBase : end
if (lo > hi) {
const swap = lo
lo = hi
hi = swap
}
if (lo === hi) {
const eps = Math.max(1, Math.round(end * 0.0015))
lo = Math.max(1, end - eps)
hi = end + eps
}
const series: number[] = []
for (let i = 0; i < FALLBACK_CHART_POINTS; i += 1) {
const t = i / (FALLBACK_CHART_POINTS - 1)
const wave = 0.5 + 0.5 * Math.sin(t * Math.PI * 2.2)
const ranged = lo + (hi - lo) * wave
const blended = ranged * (1 - t) + end * t
series.push(i === FALLBACK_CHART_POINTS - 1 ? end : Math.round(blended))
}
return sanitizeChartValues(series)
}
function commitChartValues(values: number[], persist = true): boolean {
const cleaned = sanitizeChartValues(values)
if (cleaned.length < 2) return false
rawChartValues.value = cleaned
if (persist) writeCachedChartValues(cleaned)
return true
}
function ensureChartValuesFromAvailablePrice() {
if (rawChartValues.value.length >= 2) return
const cached = readCachedChartValues()
if (commitChartValues(cached, false)) return
commitChartValues(buildFallbackChartValuesFromPrice(), false)
}
const effectiveRawChartValues = computed(() => {
if (rawChartValues.value.length >= 2) return rawChartValues.value
return buildFallbackChartValuesFromPrice()
})
const chartValues = computed(() => {
const raw = effectiveRawChartValues.value
// کاتد: بدون مارک‌آپ — مفتول: +۶٪ مثل قیمت لحظه‌ای
if (activeProduct.value === 'cathode') {
return raw.filter((value) => Number.isFinite(value) && value > 0)
}
return raw
.map((value) => applyMetalMarkup(value, 'maftol'))
.filter((value) => Number.isFinite(value) && value > 0)
})
const wireChartValues = computed(() =>
effectiveRawChartValues.value
.map((value) => applyMetalMarkup(value, LIVE_SECTION_METAL))
.filter((value) => Number.isFinite(value) && value > 0),
) )
/** سکشن live: دقیقاً همان سری فعلی Home برای بازه انتخاب‌شده */
const liveSectionChartValues = computed(() => homeChartValues.value)
const dayChangePercent = computed(() => { const dayChangePercent = computed(() => {
const values = chartValues.value const values = chartValues.value
if (values.length >= 2 && values[0] > 0) { if (values.length >= 2 && values[0] > 0) {
@@ -1028,83 +1080,47 @@ const dayChangePercent = computed(() => {
return percentPriceChanged() return percentPriceChanged()
}) })
const wireDayChangePercent = computed(() => {
const values = wireChartValues.value
if (values.length >= 2 && values[0] > 0) {
const first = values[0]
const last = values[values.length - 1]
return ((last - first) / first) * 100
}
if (!wireLiveUnitPrice.value) return 0
return percentPriceChanged()
})
const dayChangeAmountLabel = computed(() => { const dayChangeAmountLabel = computed(() => {
const values = chartValues.value const values = chartValues.value
if (values.length < 2) return '' if (values.length < 2) return ''
const delta = Math.round(values[values.length - 1] - values[0]) const delta = Math.round(values[values.length - 1] - values[0])
if (!delta) return '' if (!delta) return ''
const abs = usePriceWithoutType(Math.abs(delta)) const abs = usePriceWithoutType(Math.abs(delta))
return `${delta > 0 ? '+' : '−'}${abs} تومان` return `${delta > 0 ? '+' : '−'}${abs} ریال`
})
const wireDayChangeAmountLabel = computed(() => {
const values = wireChartValues.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} تومان`
}) })
const dayRangePrices = computed(() => { const dayRangePrices = computed(() => {
// پایه از buy/sell API — سپس مارک‌آپ مفتول برای سکشن live const metal = selectedMetalType()
const buy = Number(lastPrice.value?.buy ?? 0) return resolveMetalDayRangePrices({
const sell = Number(lastPrice.value?.sell ?? 0) price: lastPrice.value,
const fromApi = { metal,
high: buy > 0 ? applyMetalMarkup(buy, LIVE_SECTION_METAL) : 0, metalRatio: CHART_METAL_MARKUP_RATIO[metal],
low: })
sell > 0
? applyMetalMarkup(sell, LIVE_SECTION_METAL)
: buy > 0
? applyMetalMarkup(buy, LIVE_SECTION_METAL)
: 0,
}
const values = wireChartValues.value
if (values.length > 0) {
const chartHigh = Math.max(...values)
const chartLow = Math.min(...values)
return {
high: fromApi.high > 0 ? Math.max(fromApi.high, chartHigh) : chartHigh,
low: fromApi.low > 0 ? Math.min(fromApi.low, chartLow) : chartLow,
}
}
return fromApi
}) })
const liveMetrics = computed(() => { const liveMetrics = computed(() => {
const high = dayRangePrices.value.high const high = dayRangePrices.value.high
const low = dayRangePrices.value.low const low = dayRangePrices.value.low
const range = high > 0 && low > 0 ? Math.max(0, Math.round(high - low)) : 0 const range = high > 0 && low > 0 ? Math.max(0, Math.round(high - low)) : 0
const change = wireDayChangePercent.value const change = dayChangePercent.value
return [ return [
{ {
label: 'بالاترین قیمت روز', label: 'بالاترین قیمت روز',
value: high > 0 ? usePriceWithoutType(high) : '—', value: high > 0 ? usePriceWithoutType(high) : '—',
footer: 'تومان / کیلو', footer: 'ریال / کیلو',
tone: 'up' as const, tone: 'up' as const,
}, },
{ {
label: 'کمترین قیمت روز', label: 'کمترین قیمت روز',
value: low > 0 ? usePriceWithoutType(low) : '—', value: low > 0 ? usePriceWithoutType(low) : '—',
footer: 'تومان / کیلو', footer: 'ریال / کیلو',
tone: 'down' as const, tone: 'down' as const,
}, },
{ {
label: 'دامنه قیمت روز', label: 'دامنه قیمت روز',
value: range > 0 ? usePriceWithoutType(range) : '—', value: range > 0 ? usePriceWithoutType(range) : '—',
footer: 'تومان / کیلو', footer: 'ریال / کیلو',
tone: '' as const, tone: '' as const,
}, },
{ {
@@ -1117,21 +1133,22 @@ const liveMetrics = computed(() => {
}) })
const forecastTrendLabel = computed(() => { const forecastTrendLabel = computed(() => {
const change = wireDayChangePercent.value const change = dayChangePercent.value
if (change > 0.4) return 'روند ملایم صعودی' if (change > 0.4) return 'روند ملایم صعودی'
if (change < -0.4) return 'روند ملایم نزولی' if (change < -0.4) return 'روند ملایم نزولی'
return 'روند خنثی' return 'روند خنثی'
}) })
const forecastTrendText = computed(() => { const forecastTrendText = computed(() => {
const change = wireDayChangePercent.value const change = dayChangePercent.value
const product = liveProductName.value
if (change > 0.4) { if (change > 0.4) {
return 'بر اساس داده‌های ۲۴ ساعت اخیر، قیمت مفتول مس روندی صعودی و پایدار داشته است.' return `بر اساس داده‌های ۲۴ ساعت اخیر، قیمت ${product} روندی صعودی و پایدار داشته است.`
} }
if (change < -0.4) { if (change < -0.4) {
return 'بر اساس داده‌های ۲۴ ساعت اخیر، قیمت مفتول مس روندی نزولی داشته است.' return `بر اساس داده‌های ۲۴ ساعت اخیر، قیمت ${product} روندی نزولی داشته است.`
} }
return 'بر اساس داده‌های ۲۴ ساعت اخیر، قیمت مفتول مس نسبتاً پایدار بوده است.' return `بر اساس داده‌های ۲۴ ساعت اخیر، قیمت ${product} نسبتاً پایدار بوده است.`
}) })
function downsampleValues(values: number[], maxPoints: number) { function downsampleValues(values: number[], maxPoints: number) {
@@ -1182,7 +1199,7 @@ function buildLandingChartPaths(
const landingChart = computed(() => buildLandingChartPaths(chartValues.value)) const landingChart = computed(() => buildLandingChartPaths(chartValues.value))
const liveSectionChart = computed(() => const liveSectionChart = computed(() =>
buildLandingChartPaths(wireChartValues.value, LIVE_CHART_WIDTH, LIVE_CHART_HEIGHT), buildLandingChartPaths(liveSectionChartValues.value, LIVE_CHART_WIDTH, LIVE_CHART_HEIGHT),
) )
const investmentDisplay = computed(() => const investmentDisplay = computed(() =>
@@ -1211,38 +1228,6 @@ function onInvestmentInput(event: Event) {
investmentAmount.value = raw ? Number(raw) : 0 investmentAmount.value = raw ? Number(raw) : 0
} }
function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp: number; value: number }>> | null {
if (!response || typeof response !== 'object') return null
const body = response as Record<string, unknown>
const nested = body.data
if (nested && typeof nested === 'object' && 'prices' in nested) {
return (nested as { prices: Record<string, Array<{ timestamp: number; value: number }>> }).prices
}
if ('prices' in body) {
return body.prices as Record<string, Array<{ timestamp: number; value: number }>>
}
return null
}
/** مثل Home: سری DAILY از /customer/prices/all با value - 1 */
async function fetchHomeStyleDailySeries(): Promise<number[]> {
const response = await useAxios.post('/customer/prices/all', undefined, {
meta: { errorMessage: 'none' },
})
const prices = resolvePricesMap(response)
const daily = prices?.DAILY ?? []
return sanitizeChartValues(daily.map((item) => Number(item.value) - 1))
}
/** fallback عمومی برای لندینگ بدون لاگین */
async function fetchCandleSeries(): Promise<number[]> {
const candles = await useAxios.get<Array<Array<number>>>('/api/candle', {
meta: { errorMessage: 'none' },
})
if (!Array.isArray(candles) || candles.length === 0) return []
return sanitizeChartValues(candles.map((item) => Number(item?.[1] ?? 0)))
}
async function loadLandingPrice() { async function loadLandingPrice() {
try { try {
const data = await useAxios.get<Price>('/api/price/last', { const data = await useAxios.get<Price>('/api/price/last', {
@@ -1256,35 +1241,10 @@ async function loadLandingPrice() {
} }
} }
/** /** همان init نمودار Home: fetchPrices + metal offsets */
* نمودار لندینگ = تاریخچه (مثل Home)، مستقل از باز/بسته بودن بازار.
* اولویت: prices/all → candle → کش → آخرین مظنه
*/
async function loadLandingChart() { async function loadLandingChart() {
try { setChartMetal(selectedMetalType())
if (rawChartValues.value.length < 2) { await Promise.all([fetchPrices(true), loadMetalChartOffsets(true)])
commitChartValues(readCachedChartValues(), false)
}
try {
const series = await fetchHomeStyleDailySeries()
if (commitChartValues(series)) return
} catch (error) {
console.error('Landing chart fetch failed (prices/all):', error)
}
try {
const series = await fetchCandleSeries()
if (commitChartValues(series)) return
} catch (error) {
console.error('Landing candle fetch failed:', error)
}
ensureChartValuesFromAvailablePrice()
} finally {
ensureChartValuesFromAvailablePrice()
chartReady.value = true
}
} }
let mobileNavMq: MediaQueryList | null = null let mobileNavMq: MediaQueryList | null = null
@@ -1299,7 +1259,8 @@ function closeMobileMenu() {
} }
function goToLoginApp() { function goToLoginApp() {
window.location.href = getLoginAppUrl() const url = getLoginAppUrl()
window.open(url, '_blank', 'noopener,noreferrer')
} }
function goLoginAndCloseMenu() { function goLoginAndCloseMenu() {
@@ -1324,30 +1285,33 @@ watch(isMobileMenuOpen, (open) => {
syncBodyScrollLock(open) syncBodyScrollLock(open)
}) })
watch(lastPrice, () => {
ensureChartValuesFromAvailablePrice()
})
onMounted(() => { onMounted(() => {
mobileNavMq = window.matchMedia(MOBILE_NAV_MQ) mobileNavMq = window.matchMedia(MOBILE_NAV_MQ)
mobileNavMq.addEventListener('change', onMobileNavMqChange) mobileNavMq.addEventListener('change', onMobileNavMqChange)
window.addEventListener('keydown', onEscapeKey) window.addEventListener('keydown', onEscapeKey)
window.addEventListener(PWA_MANIFEST_READY_EVENT, onPwaManifestReady)
syncInstallAvailability()
commitChartValues(readCachedChartValues(), false) setLiveProduct(activeProduct.value)
void loadLandingPrice()
void loadLandingPrice().then(() => {
ensureChartValuesFromAvailablePrice()
})
void loadLandingChart() void loadLandingChart()
pricePollTimer = setInterval(() => { pricePollTimer = setInterval(() => {
void loadLandingPrice() void loadLandingPrice()
}, PRICE_POLL_MS) }, PRICE_POLL_MS)
void nextTick(() => {
setupMetricsCountUp()
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
mobileNavMq?.removeEventListener('change', onMobileNavMqChange) mobileNavMq?.removeEventListener('change', onMobileNavMqChange)
window.removeEventListener('keydown', onEscapeKey) window.removeEventListener('keydown', onEscapeKey)
window.removeEventListener(PWA_MANIFEST_READY_EVENT, onPwaManifestReady)
syncBodyScrollLock(false) syncBodyScrollLock(false)
metricsObserver?.disconnect()
metricsObserver = undefined
if (metricsRaf) cancelAnimationFrame(metricsRaf)
if (pricePollTimer) { if (pricePollTimer) {
clearInterval(pricePollTimer) clearInterval(pricePollTimer)
pricePollTimer = undefined pricePollTimer = undefined
@@ -1363,12 +1327,134 @@ const partners = [
] ]
const metrics = [ const metrics = [
{ prefix: '+', value: '۲,۴۰۰', suffix: '', label: 'مشتریان' }, { prefix: '+', target: 2400, suffix: '', label: 'مشتریان', grouped: true },
{ prefix: '+', value: '۱۸,۵۰۰', suffix: '', label: 'سفارش‌ها' }, { prefix: '+', target: 18500, suffix: '', label: 'سفارش‌ها', grouped: true },
{ prefix: '', value: '۳۱', suffix: '', label: 'حجم تولید روزانه (تن)' }, { prefix: '', target: 31, suffix: '', label: 'حجم تولید روزانه (تن)', grouped: false },
{ prefix: '', value: '۹۹', suffix: '٪', label: 'تراکنش موفق' }, { prefix: '', target: 99, suffix: '٪', label: 'تراکنش موفق', grouped: false },
] ]
function toFaDisplayNumber(value: number, grouped: boolean) {
const rounded = Math.round(value)
const raw = grouped ? formatNumber(rounded, true) : String(rounded)
return raw.replace(/\d/g, (digit) => '۰۱۲۳۴۵۶۷۸۹'[Number(digit)] ?? digit)
}
const displayMetrics = computed(() =>
metrics.map((metric, index) => ({
prefix: metric.prefix,
suffix: metric.suffix,
label: metric.label,
display: toFaDisplayNumber(metricAnimatedValues.value[index] ?? 0, metric.grouped),
})),
)
let metricsObserver: IntersectionObserver | undefined
let metricsRaf = 0
function isStandaloneDisplay() {
return (
window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: fullscreen)').matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
)
}
function isIosDevice() {
const ua = window.navigator.userAgent
return (
/iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
)
}
function syncInstallAvailability() {
isAppInstalledOrStandalone.value = isStandaloneDisplay()
// Chrome: فقط وقتی blob manifest + SW آماده و deferred موجود باشد
canPromptInstall.value =
isPwaManifestReady() && Boolean(getDeferredPwaInstallPrompt())
}
const canInstallWebApp = computed(() => {
if (isAppInstalledOrStandalone.value) return false
if (canPromptInstall.value) return true
// iOS رویداد beforeinstallprompt ندارد — راهنمای Add to Home Screen
return isIosDevice()
})
function onPwaManifestReady() {
syncInstallAvailability()
}
/**
* همان Chrome Install Prompt اپ اصلی — بدون redirect به /login.
* اگر deferred prompt نباشد: روی iOS راهنما؛ در غیر این صورت هیچ fake-successی نیست.
*/
async function installWebApp() {
if (isAppInstalledOrStandalone.value) return
// Chrome/Edge: فقط deferred معتبر + readiness
if (getDeferredPwaInstallPrompt() && isPwaManifestReady()) {
const prompted = await promptDeferredPwaInstall()
if (prompted) {
canPromptInstall.value = false
return
}
}
if (isIosDevice()) {
showPwaGuide.value = true
return
}
syncInstallAvailability()
}
function onPwaGuideConfirm() {
showPwaGuide.value = false
}
function animateMetricsCountUp(durationMs = 1600) {
if (metricsCountStarted.value) return
metricsCountStarted.value = true
const start = performance.now()
const from = metricAnimatedValues.value.slice()
const tick = (now: number) => {
const t = Math.min(1, (now - start) / durationMs)
const eased = 1 - (1 - t) ** 3
metricAnimatedValues.value = metrics.map((metric, index) =>
from[index] + (metric.target - from[index]) * eased,
)
if (t < 1) {
metricsRaf = requestAnimationFrame(tick)
} else {
metricAnimatedValues.value = metrics.map((metric) => metric.target)
}
}
metricsRaf = requestAnimationFrame(tick)
}
function setupMetricsCountUp() {
const el = statsMetricsEl.value
if (!el || typeof IntersectionObserver === 'undefined') {
animateMetricsCountUp()
return
}
metricsObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
animateMetricsCountUp()
metricsObserver?.disconnect()
metricsObserver = undefined
}
},
{ threshold: 0.35 },
)
metricsObserver.observe(el)
}
/** ترتیب RTL: راست → چپ مطابق تصویر */ /** ترتیب RTL: راست → چپ مطابق تصویر */
const whyFeatures = [ const whyFeatures = [
{ {
@@ -1515,28 +1601,28 @@ const footerColumns = [
{ {
title: 'محصول', title: 'محصول',
links: [ links: [
{ label: 'قیمت لحظه‌ای', href: '#' }, { label: 'قیمت لحظه‌ای', href: '#dlanding-live', action: 'scroll' as const },
{ label: 'خرید مس', href: '#' }, { label: 'خرید مس', href: '#', action: 'login' as const },
{ label: 'فروش مس', href: '#' }, { label: 'فروش مس', href: '#', action: 'login' as const },
{ label: 'ماشین‌حساب', href: '#' }, { label: 'ماشین‌حساب', href: '#online-trade', action: 'scroll' as const },
], ],
}, },
{ {
title: 'شرکت', title: 'شرکت',
links: [ links: [
{ label: 'چرا بورس مس؟', href: '#why-boursecu' }, { label: 'چرا بورس مس؟', href: '#why-boursecu', action: 'scroll' as const },
{ label: 'پشتوانه', href: '#' }, { label: 'پشتوانه', href: '#', action: 'none' as const },
{ label: 'نظرات مشتریان', href: '#' }, { label: 'نظرات مشتریان', href: '#', action: 'none' as const },
{ label: 'سوالات متداول', href: '#faq' }, { label: 'سوالات متداول', href: '#faq', action: 'scroll' as const },
], ],
}, },
{ {
title: 'قانونی', title: 'قانونی',
links: [ links: [
{ label: 'قوانین و مقررات', href: '#rules' }, { label: 'قوانین و مقررات', href: '#rules', action: 'scroll' as const },
{ label: 'حریم خصوصی', href: '#' }, { label: 'حریم خصوصی', href: '#', action: 'none' as const },
{ label: 'شرایط استفاده', href: '#' }, { label: 'شرایط استفاده', href: '#', action: 'none' as const },
{ label: 'تماس با ما', href: '#' }, { label: 'تماس با ما', href: '#', action: 'none' as const },
], ],
}, },
] ]
@@ -1545,11 +1631,23 @@ function toggleFaq(index: number) {
openFaqIndex.value = openFaqIndex.value === index ? -1 : index openFaqIndex.value = openFaqIndex.value === index ? -1 : index
} }
function scrollToRules() { function scrollToLiveChart() {
document.getElementById('rules')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) document.getElementById('dlanding-live')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} }
function installWebApp() { function onFooterLinkClick(
window.location.href = getLoginInstallPwaUrl() event: MouseEvent,
link: { href: string; action?: 'login' | 'scroll' | 'none' },
) {
if (link.action === 'login') {
event.preventDefault()
goToLoginApp()
return
}
if (link.action === 'scroll' && link.href.startsWith('#')) {
event.preventDefault()
const id = link.href.slice(1)
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
} }
</script> </script>
@@ -964,10 +964,16 @@ body.dlanding-menu-open {
transform 0.2s ease, transform 0.2s ease,
box-shadow 0.2s ease; box-shadow 0.2s ease;
&:hover { &:hover:not(:disabled) {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16); box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
} }
&:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
} }
&__btn-icon { &__btn-icon {
@@ -1247,6 +1253,55 @@ body.dlanding-menu-open {
font-weight: 700; font-weight: 700;
} }
&__product-block {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
&__product-tabs,
&__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;
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition:
background-color 0.18s ease,
color 0.18s ease;
}
.is-active {
background: #c98a5f;
color: #fff;
}
}
&__product-tabs {
button {
min-width: 64px;
}
}
&__product-dot { &__product-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -1380,6 +1435,7 @@ body.dlanding-menu-open {
color: #94a3b8; color: #94a3b8;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
flex-wrap: wrap;
} }
&__chart { &__chart {
@@ -3272,6 +3328,18 @@ body.dlanding-menu-open {
order: -1; order: -1;
} }
&__main-top {
flex-direction: column;
align-items: stretch;
}
&__status {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
&__price { &__price {
font-size: clamp(28px, 5vw, 40px); font-size: clamp(28px, 5vw, 40px);
} }