edit button install pwa
This commit is contained in:
Vendored
+3
@@ -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
@@ -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' },
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 + ۶٪ */
|
function setLiveChartRange(range: LiveChartRange) {
|
||||||
const liveUnitPrice = computed(() => {
|
updateChartData(range)
|
||||||
if (activeProduct.value === 'cathode') {
|
}
|
||||||
return baseBuyPrice.value
|
|
||||||
}
|
|
||||||
return applyMetalMarkup(baseBuyPrice.value, 'maftol')
|
|
||||||
})
|
|
||||||
|
|
||||||
/** قیمت مفتول مس — همیشه برای سکشن قیمت لحظهای لندینگ */
|
/** مقادیر Y همان سری Home (بعد از مارکآپ فلز) */
|
||||||
const wireLiveUnitPrice = computed(() =>
|
const homeChartValues = computed(() =>
|
||||||
applyMetalMarkup(baseBuyPrice.value, LIVE_SECTION_METAL),
|
(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 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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user