edit button install pwa

This commit is contained in:
2026-08-09 11:07:42 +03:30
parent 06349ab208
commit fa42e96fa0
7 changed files with 1128 additions and 306 deletions
+4 -1
View File
@@ -8,4 +8,7 @@ export const getPrice = async () => await useAxios.get<Price>(`/api/price/up`)
export const getLastPrice = async () => await useAxios.get<Price>(`/api/price/last`)
export const getAllPrices = async () => await useAxios.post('/customer/prices/all')
export const getAllPrices = async () =>
await useAxios.post('/customer/prices/all', undefined, {
meta: { errorMessage: 'none' },
})
+5
View File
@@ -11,10 +11,15 @@ import { loadAppConfig } from '@/config/load-config'
import { setAppConfig } from '@/config/app-config'
import { 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)
+589
View File
@@ -0,0 +1,589 @@
import type { AppConfig } from '@/config/types'
import { getConfig, getRawAppConfig } from '@/config/app-config'
import { getConfigUrls, sortConfigUrls } from '@/config/load-config'
import {
clearCachedPwaManifest,
readCachedPwaManifest,
writeCachedPwaManifest,
type PwaManifest
} from '@/modules/pwa/manifest-cache'
export { clearCachedPwaManifest, type PwaManifest, type PwaManifestIcon } from '@/modules/pwa/manifest-cache'
export const PWA_MANIFEST_READY_KEY = 'pwa_manifest_ready'
export const PWA_MANIFEST_READY_EVENT = 'pwa-manifest-ready'
type BeforeInstallPromptEventLike = Event & {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
/** نگه‌داری رویداد beforeinstallprompt تا فراخوانی prompt() */
let deferredInstallPrompt: BeforeInstallPromptEventLike | null = null
let installCaptureInitialized = false
const MANIFEST_RETRY_DELAY_MS = 500
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function normalizeBaseUrl(url: string) {
return url.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
}
/** همان baseای که get_user از آن لود شده (کش شده در localStorage) */
export function getConfigBaseFromStorage() {
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
if (!lastSuccess) return ''
return normalizeBaseUrl(lastSuccess)
}
function configUrlToManifestUrl(configUrl: string) {
return `${configUrl.replace(/\/+$/, '')}/manifest.json`
}
/** همان ۵ URL پایهٔ get_user — اولویت با CONFIG_Success_URL */
export function resolveManifestUrls(config?: AppConfig | null): string[] {
const explicit = getConfig<string>('manifest_url')
if (explicit) return [explicit]
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
}
/** اولین کاندید — برای سازگاری */
export function resolveManifestUrl(config?: AppConfig | null) {
return resolveManifestUrls(config)[0] ?? ''
}
function isValidManifest(payload: unknown): payload is PwaManifest {
if (!payload || typeof payload !== 'object') return false
const manifest = payload as PwaManifest
const icons = manifest.icons
if (!Array.isArray(icons) || icons.length === 0) return false
return icons.some((icon) => typeof icon?.src === 'string' && icon.src.trim().length > 0)
}
async function fetchManifestOnce(url: string, timeout = 6000): Promise<PwaManifest> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/manifest+json, application/json' },
cache: 'default',
signal: controller.signal,
priority: 'high'
})
if (!response.ok) {
throw new Error(`Manifest request failed: ${response.status}`)
}
const payload = await response.json()
if (!isValidManifest(payload)) {
throw new Error('Manifest response is missing valid icons')
}
return payload
} finally {
clearTimeout(timeoutId)
}
}
/** هر URL یک بار — مثل لوپ get_user */
async function fetchManifestTryingUrls(
urls: string[]
): Promise<{ manifest: PwaManifest; manifestUrl: string }> {
if (!urls.length) {
throw new Error('No manifest URL candidates')
}
let lastError: unknown = null
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
try {
const manifest = await fetchManifestOnce(url)
return { manifest, manifestUrl: url }
} catch (error) {
lastError = error
console.warn(`PWA manifest failed (${url}), candidate ${index + 1}/${urls.length}`, error)
}
if (index < urls.length - 1) {
await sleep(MANIFEST_RETRY_DELAY_MS)
}
}
throw lastError ?? new Error('PWA manifest could not be loaded from any server')
}
let manifestBlobUrl: string | null = null
function revokeManifestBlobUrl() {
if (manifestBlobUrl) {
URL.revokeObjectURL(manifestBlobUrl)
manifestBlobUrl = null
}
}
function isAbsoluteWebUrl(src: string) {
return /^https?:\/\//i.test(src) || src.startsWith('//')
}
function toAbsoluteAssetUrl(src: string, base: string) {
const value = String(src || '').trim()
if (!value) return ''
if (value.startsWith('//')) {
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'
return `${protocol}${value}`
}
if (/^(https?:\/\/|data:|blob:)/i.test(value)) return value
if (!base) return value
return `${base.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}`
}
/** path داخل همین اپ — دامنهٔ سرور (montpar.ir) نادیده گرفته می‌شود */
function toManifestPath(url: string | undefined, fallback = '/') {
const defaultPath = fallback.startsWith('/') ? fallback : `/${fallback}`
if (!url?.trim()) return defaultPath
try {
const trimmed = url.trim()
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
return trimmed
}
const parsed = new URL(trimmed)
const path = `${parsed.pathname || '/'}${parsed.search}${parsed.hash}`
if (parsed.origin !== window.location.origin) {
return path || '/'
}
return path || '/'
} catch {
return defaultPath
}
}
/**
* blob manifest فقط URL مطلق هم‌origin را می‌پذیرد (مثلاً http://192.168.x.x:3100/).
* مسیر نسبی مثل "/" → "property ignored, URL is invalid".
*/
function toSameOriginManifestUrl(url: string | undefined, fallbackPath = '/') {
const path = toManifestPath(url, fallbackPath)
return new URL(path, window.location.origin).href
}
function normalizeManifestId(id: string | undefined, startUrl: string) {
const value = String(id || '').trim()
if (!value || value === '/') return startUrl
try {
const parsed = new URL(value, window.location.origin)
if (parsed.origin === window.location.origin) return parsed.href
} catch {
// ignore
}
return startUrl
}
function guessMimeType(src: string) {
const lower = src.split('?')[0].toLowerCase()
if (lower.endsWith('.png')) return 'image/png'
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
if (lower.endsWith('.webp')) return 'image/webp'
return 'image/png'
}
function mapManifestIcons(manifest: PwaManifest, base: string) {
return (manifest.icons || [])
.map((icon) => {
const src = toAbsoluteAssetUrl(icon.src, base)
return {
src,
sizes: icon.sizes || '512x512',
type: guessMimeType(src),
purpose: icon.purpose || 'any'
}
})
.filter((icon) => isAbsoluteWebUrl(icon.src))
}
/** فقط آیکون‌های داخل monifist.json — بدون آواتار / pwa-icons / env */
function resolveInstallIcons(manifest: PwaManifest) {
const base = getConfigBaseFromStorage()
const mapped = mapManifestIcons(manifest, base)
if (!mapped.length) {
throw new Error('Manifest icons could not be resolved to absolute URLs')
}
return mapped
}
function prepareManifestForInstall(manifest: PwaManifest) {
const scopeUrl = toSameOriginManifestUrl(manifest.scope ?? manifest.start_url, '/')
const startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
const icons = resolveInstallIcons(manifest)
const name = manifest.short_name || manifest.name
if (!name) {
throw new Error('Manifest is missing name/short_name')
}
if (!manifest.theme_color) {
throw new Error('Manifest is missing theme_color')
}
// start_url باید داخل scope باشد
const scopeBase = scopeUrl.endsWith('/') ? scopeUrl : `${scopeUrl}/`
const effectiveScope = startUrl.startsWith(scopeBase) ? scopeUrl : toSameOriginManifestUrl('/', '/')
return {
id: normalizeManifestId(manifest.id, startUrl),
name,
short_name: manifest.short_name || manifest.name || name,
lang: manifest.lang || 'fa',
dir: manifest.dir || 'rtl',
start_url: startUrl,
scope: effectiveScope,
display: manifest.display || 'standalone',
background_color: manifest.background_color || '#ffffff',
theme_color: manifest.theme_color,
description: manifest.description || name,
icons
}
}
function pickManifestIconUrl(manifest: PwaManifest, preferredSizes: number[]) {
const icons = manifest.icons ?? []
for (const size of preferredSizes) {
const target = `${size}x${size}`
const match = icons.find((icon) => String(icon.sizes || '').includes(target))
if (match?.src) return match.src
}
const sorted = [...icons].sort((a, b) => {
const aSize = Number.parseInt(String(a.sizes || '0'), 10) || 0
const bSize = Number.parseInt(String(b.sizes || '0'), 10) || 0
return bSize - aSize
})
return sorted[0]?.src || ''
}
function upsertLink(rel: string, attrs: Record<string, string>) {
const selectorParts = Object.entries(attrs)
.filter(([key]) => key !== 'href')
.map(([key, value]) => `[${key}="${value}"]`)
.join('')
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]${selectorParts}`)
if (!link) {
link = document.createElement('link')
link.rel = rel
document.head.appendChild(link)
}
Object.entries(attrs).forEach(([key, value]) => {
link!.setAttribute(key, value)
})
return link
}
function upsertMeta(name: string, content: string) {
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
if (!meta) {
meta = document.createElement('meta')
meta.name = name
document.head.appendChild(meta)
}
meta.content = content
}
function removeAllManifestLinks() {
document.querySelectorAll<HTMLLinkElement>("link[rel='manifest']").forEach((node) => node.remove())
}
function removeDynamicManifestLinks() {
removeAllManifestLinks()
revokeManifestBlobUrl()
document
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
.forEach((node) => node.remove())
}
function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
document
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
.forEach((node) => node.remove())
const appleSizes = [180, 167, 152, 120]
const used = new Set<string>()
appleSizes.forEach((size) => {
const href = pickManifestIconUrl(manifest, [size, 192, 512])
if (!href || used.has(href)) return
used.add(href)
upsertLink('apple-touch-icon', {
'data-dynamic-pwa': 'true',
sizes: `${size}x${size}`,
href
})
})
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
if (primary) {
upsertLink('apple-touch-icon', {
'data-dynamic-pwa': 'true',
href: primary
})
}
}
export async function applyPreparedManifestToDocument(
normalized: PwaManifest,
manifestUrl: string
) {
removeDynamicManifestLinks()
if (!normalized.icons?.length) {
throw new Error('Manifest has no valid absolute icon URLs')
}
const title = normalized.short_name || normalized.name
if (!title) {
throw new Error('Prepared manifest is missing title')
}
document.title = title
const blob = new Blob([JSON.stringify(normalized)], {
type: 'application/manifest+json'
})
manifestBlobUrl = URL.createObjectURL(blob)
upsertLink('manifest', {
'data-dynamic-pwa': 'true',
href: manifestBlobUrl
})
if (normalized.theme_color) {
upsertMeta('theme-color', normalized.theme_color)
}
upsertMeta('apple-mobile-web-app-title', title)
upsertMeta('mobile-web-app-capable', 'yes')
upsertMeta('apple-mobile-web-app-capable', 'yes')
upsertMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')
applyAppleTouchIconsFromManifest(normalized)
const favicon = pickManifestIconUrl(normalized, [192, 180, 128, 96, 72])
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
if (favicon) {
upsertLink('icon', { type: 'image/png', href: favicon })
}
}
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
const prepared = prepareManifestForInstall(manifest)
await applyPreparedManifestToDocument(prepared, manifestUrl)
}
export function setPwaManifestReady(enabled: boolean) {
if (enabled) {
sessionStorage.setItem(PWA_MANIFEST_READY_KEY, '1')
} else {
sessionStorage.removeItem(PWA_MANIFEST_READY_KEY)
}
}
export function isPwaManifestReady() {
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
}
export function getDeferredPwaInstallPrompt() {
return deferredInstallPrompt
}
/** اگر deferred prompt موجود باشد، دیالوگ نصب مرورگر را نشان می‌دهد */
export async function promptDeferredPwaInstall(): Promise<boolean> {
const event = deferredInstallPrompt
if (!event || typeof event.prompt !== 'function') return false
deferredInstallPrompt = null
try {
await event.prompt()
await event.userChoice.catch(() => undefined)
return true
} catch {
return false
}
}
/** هرچه زودتر در main.ts — قبل از bootstrap */
export function initPwaInstallCapture() {
if (installCaptureInitialized || typeof window === 'undefined') return
installCaptureInitialized = true
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredInstallPrompt = e as BeforeInstallPromptEventLike
if (isPwaManifestReady()) {
notifyPwaManifestReady(e)
return
}
console.info('PWA install prompt captured, waiting for manifest + service worker')
})
}
export function notifyPwaManifestReady(installEvent?: Event | null) {
if (!isPwaManifestReady()) return
if (installEvent) {
deferredInstallPrompt = installEvent as BeforeInstallPromptEventLike
}
const event = installEvent ?? deferredInstallPrompt
window.dispatchEvent(
new CustomEvent(PWA_MANIFEST_READY_EVENT, {
detail: { installEvent: event }
})
)
if (!event) {
console.info('PWA ready (manifest + SW). Waiting for beforeinstallprompt…', {
secureContext: window.isSecureContext,
origin: window.location.origin
})
}
}
async function applyCachedManifestEntry(
cached: ReturnType<typeof readCachedPwaManifest>
): Promise<boolean> {
if (!cached) return false
try {
const prepared = prepareManifestForInstall(cached.manifest)
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
setPwaManifestReady(true)
return true
} catch (error) {
console.warn('Cached PWA manifest invalid, refetching:', error)
clearCachedPwaManifest()
return false
}
}
/**
* بعد از loadAppConfig / setAppConfig صدا بزن.
* get_user دوباره fetch نمی‌شود — manifest از همان ۵ URL پایهٔ get_user امتحان می‌شود.
*/
export async function setupPwaManifest(config?: AppConfig | null): Promise<boolean> {
const raw = config ?? getRawAppConfig()
const userId = raw?.user?.id
const configBase = getConfigBaseFromStorage()
const manifestUrls = resolveManifestUrls(raw)
if (!manifestUrls.length) {
console.warn('PWA manifest skipped: no manifest URL candidates')
removeDynamicManifestLinks()
setPwaManifestReady(false)
return false
}
const cached = configBase ? readCachedPwaManifest(userId, configBase) : null
if (cached && (await applyCachedManifestEntry(cached))) {
console.info('PWA manifest applied from cache:', cached.manifest_url)
return true
}
try {
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
const prepared = prepareManifestForInstall(manifest)
await applyPreparedManifestToDocument(prepared, manifestUrl)
setPwaManifestReady(true)
if (userId != null && userId !== '') {
const effectiveConfigBase =
configBase || normalizeBaseUrl(manifestUrl.replace(/\/manifest\.json$/i, ''))
writeCachedPwaManifest({
cached_at: Date.now(),
user_id: userId,
config_base: effectiveConfigBase,
manifest_url: manifestUrl,
manifest: prepared
})
}
console.info('PWA manifest loaded once:', manifestUrl)
return true
} catch (error) {
console.warn('PWA manifest unavailable, PWA disabled this session:', error)
removeDynamicManifestLinks()
setPwaManifestReady(false)
return false
}
}
let manifestBootstrapStarted = false
/**
* بلافاصله بعد از setAppConfig — بدون await در bootstrap.
* اپ منتظر مانیفست نمی‌ماند؛ SW بعد از موفقیت در پس‌زمینه ثبت می‌شود.
*/
export function startPwaManifest(config?: AppConfig | null) {
if (manifestBootstrapStarted) return
manifestBootstrapStarted = true
void setupPwaManifest(config).then((ready) => {
if (!ready) return
void import('@/modules/pwa/register-pwa').then(({ registerAppPwa }) => registerAppPwa())
})
}
+56
View File
@@ -0,0 +1,56 @@
import { isPwaManifestReady, notifyPwaManifestReady } from '@/modules/pwa/manifest'
let swRegistrationStarted = false
function isMainAppServiceWorker(scriptURL: string) {
try {
const path = new URL(scriptURL, window.location.origin).pathname
return path === '/sw.js'
} catch {
return /\/sw\.js$/i.test(scriptURL)
}
}
/**
* همان SW اپ اصلی را register می‌کند — بدون vite-plugin-pwa و بدون SW جدید.
* اگر registration موجود برای /sw.js با scope / باشد، دوباره اسکریپت تازه نمی‌سازد.
*/
export function registerAppPwa() {
if (!isPwaManifestReady()) {
console.info('Skipping service worker registration: manifest not ready')
return
}
if (swRegistrationStarted) return
swRegistrationStarted = true
if (!('serviceWorker' in navigator)) {
console.warn('Service workers not supported in this browser')
notifyPwaManifestReady()
return
}
void (async () => {
try {
const existing = await navigator.serviceWorker.getRegistration('/')
const existingScript =
existing?.active?.scriptURL ||
existing?.waiting?.scriptURL ||
existing?.installing?.scriptURL ||
''
if (existing && existingScript && isMainAppServiceWorker(existingScript)) {
console.info('PWA service worker already registered:', existing.scope)
notifyPwaManifestReady()
return
}
const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
console.info('PWA service worker registered:', registration.scope)
notifyPwaManifestReady()
} catch (error: unknown) {
console.warn('PWA service worker registration failed:', error)
notifyPwaManifestReady()
}
})()
}
File diff suppressed because it is too large Load Diff
@@ -964,10 +964,16 @@ body.dlanding-menu-open {
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
}
&__btn-icon {
@@ -1247,6 +1253,55 @@ body.dlanding-menu-open {
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 {
width: 8px;
height: 8px;
@@ -1380,6 +1435,7 @@ body.dlanding-menu-open {
color: #94a3b8;
font-size: 12px;
font-weight: 500;
flex-wrap: wrap;
}
&__chart {
@@ -3272,6 +3328,18 @@ body.dlanding-menu-open {
order: -1;
}
&__main-top {
flex-direction: column;
align-items: stretch;
}
&__status {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
&__price {
font-size: clamp(28px, 5vw, 40px);
}