diff --git a/components.d.ts b/components.d.ts index 11be893..e10ca2f 100644 --- a/components.d.ts +++ b/components.d.ts @@ -7,6 +7,9 @@ export {} declare module 'vue' { 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'] BasicIcon: typeof import('./src/components/BasicPacks/BasicIcon.vue')['default'] BasicModal: typeof import('./src/components/BasicPacks/BasicModal.vue')['default'] diff --git a/src/api/home.ts b/src/api/home.ts index d230c2e..84ff302 100644 --- a/src/api/home.ts +++ b/src/api/home.ts @@ -8,4 +8,7 @@ export const getPrice = async () => await useAxios.get(`/api/price/up`) export const getLastPrice = async () => await useAxios.get(`/api/price/last`) -export const getAllPrices = async () => await useAxios.post('/customer/prices/all') \ No newline at end of file +export const getAllPrices = async () => + await useAxios.post('/customer/prices/all', undefined, { + meta: { errorMessage: 'none' }, + }) \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 234dd6f..cd95206 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,10 +11,15 @@ import { loadAppConfig } from '@/config/load-config' import { setAppConfig } from '@/config/app-config' import { applyProjectTheme } from '@/utils/config' import { useSettingsStore } from '@/store/settingsStore' +import { initPwaInstallCapture, startPwaManifest } from '@/modules/pwa/manifest' + +/** قبل از bootstrap — تا beforeinstallprompt از دست نرود */ +initPwaInstallCapture() async function bootstrapLanding() { const config = await loadAppConfig() setAppConfig(config) + startPwaManifest(config) applyProjectTheme() const app = createApp(App) diff --git a/src/modules/pwa/manifest.ts b/src/modules/pwa/manifest.ts new file mode 100644 index 0000000..0c264c7 --- /dev/null +++ b/src/modules/pwa/manifest.ts @@ -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 + 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('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 { + 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) { + const selectorParts = Object.entries(attrs) + .filter(([key]) => key !== 'href') + .map(([key, value]) => `[${key}="${value}"]`) + .join('') + + let link = document.querySelector(`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(`meta[name="${name}"]`) + + if (!meta) { + meta = document.createElement('meta') + meta.name = name + document.head.appendChild(meta) + } + + meta.content = content +} + +function removeAllManifestLinks() { + document.querySelectorAll("link[rel='manifest']").forEach((node) => node.remove()) +} + +function removeDynamicManifestLinks() { + removeAllManifestLinks() + revokeManifestBlobUrl() + + document + .querySelectorAll("link[rel='apple-touch-icon'][data-dynamic-pwa='true']") + .forEach((node) => node.remove()) +} + +function applyAppleTouchIconsFromManifest(manifest: PwaManifest) { + document + .querySelectorAll("link[rel='apple-touch-icon'][data-dynamic-pwa='true']") + .forEach((node) => node.remove()) + + const appleSizes = [180, 167, 152, 120] + const used = new Set() + + 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 { + 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 +): Promise { + 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 { + 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()) + }) +} diff --git a/src/modules/pwa/register-pwa.ts b/src/modules/pwa/register-pwa.ts new file mode 100644 index 0000000..fb97db6 --- /dev/null +++ b/src/modules/pwa/register-pwa.ts @@ -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() + } + })() +} diff --git a/src/views/base/aboutUs/desktop/DesktopAboutUsView.vue b/src/views/base/aboutUs/desktop/DesktopAboutUsView.vue index 30fa33e..f01ad0b 100644 --- a/src/views/base/aboutUs/desktop/DesktopAboutUsView.vue +++ b/src/views/base/aboutUs/desktop/DesktopAboutUsView.vue @@ -115,10 +115,9 @@ خرید و فروش مس - @@ -146,7 +145,7 @@ role="tab" :aria-selected="activeProduct === 'wire'" :class="{ 'is-active': activeProduct === 'wire' }" - @click="activeProduct = 'wire'" + @click="setLiveProduct('wire')" > مفتول @@ -155,7 +154,7 @@ role="tab" :aria-selected="activeProduct === 'cathode'" :class="{ 'is-active': activeProduct === 'cathode' }" - @click="activeProduct = 'cathode'" + @click="setLiveProduct('cathode')" > کاتد @@ -185,7 +184,7 @@
{{ formattedLivePrice }} - تومان / کیلوگرم + ریال / کیلوگرم
@@ -239,7 +238,7 @@ />
- {{ chartReady ? 'نمودار در دسترس نیست' : 'در حال بارگذاری نمودار…' }} + {{ liveChartLoading ? 'در حال بارگذاری نمودار…' : 'نمودار در دسترس نیست' }}
اکنون @@ -260,7 +259,7 @@
- تومان + ریال
@@ -321,10 +320,10 @@ -
+

قیمت لحظه‌ای مس

@@ -429,16 +433,42 @@
-
-