admin/lib/pwa.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

171 lines
5.5 KiB
TypeScript

const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '')
const SW_PATH = `${basePath}/sw.js`
const SW_SCOPE = basePath ? `${basePath}/` : '/'
const PWA_INSTALL_DISMISSED_KEY = 'pwa_install_dismissed_at'
const PWA_INSTALL_DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
export const PUSH_SUBSCRIPTION_ID_STORAGE_KEY = 'push_subscription_id'
export const isBrowser = () => typeof window !== 'undefined'
export const isIosDevice = () => {
if (!isBrowser()) return false
const ua = window.navigator.userAgent
// iPadOS 13+ reports as "MacIntel" but, unlike a real Mac, exposes touch points.
const isIpadOs13Plus = window.navigator.userAgent.includes('Mac') && navigator.maxTouchPoints > 1
return /iphone|ipad|ipod/i.test(ua) || isIpadOs13Plus
}
/**
* iOS Safari never fires `beforeinstallprompt`, so usePwa's normal
* install-prompt flow is a permanent no-op there. Any "installed" UI on iOS
* has to fall back to showing manual "Share → Add to Home Screen" steps.
*/
export const isIosSafari = () => {
if (!isIosDevice()) return false
const ua = window.navigator.userAgent
// Chrome/Firefox/Edge on iOS all embed WebKit and match /safari/i too, so
// they must be excluded explicitly.
const isOtherIosBrowser = /crios|fxios|edgios|opios|duckduckgo/i.test(ua)
return /safari/i.test(ua) && !isOtherIosBrowser
}
export const isPwaStandalone = () => {
if (!isBrowser()) return false
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
)
}
export const isPushSupported = () => {
if (!isBrowser()) return false
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
export const shouldShowInstallPrompt = () => {
if (!isBrowser() || isPwaStandalone()) return false
const dismissedAt = Number(localStorage.getItem(PWA_INSTALL_DISMISSED_KEY) ?? 0)
if (dismissedAt && Date.now() - dismissedAt < PWA_INSTALL_DISMISS_TTL_MS) {
return false
}
return true
}
export const dismissInstallPrompt = () => {
if (!isBrowser()) return
localStorage.setItem(PWA_INSTALL_DISMISSED_KEY, String(Date.now()))
}
export const registerServiceWorker = async (): Promise<ServiceWorkerRegistration | null> => {
if (process.env.NODE_ENV !== 'production' || !isBrowser() || !('serviceWorker' in navigator)) return null
try {
const registration = await navigator.serviceWorker.register(SW_PATH, { scope: SW_SCOPE })
if (registration.waiting) {
window.dispatchEvent(new CustomEvent('ghabilee:sw-update', { detail: registration }))
}
registration.addEventListener('updatefound', () => {
const installing = registration.installing
installing?.addEventListener('statechange', () => {
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
window.dispatchEvent(new CustomEvent('ghabilee:sw-update', { detail: registration }))
}
})
})
return registration
} catch {
return null
}
}
export const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
if (!isBrowser() || !('serviceWorker' in navigator)) return null
// `navigator.serviceWorker.ready` never rejects and may remain pending
// forever when registration is blocked or unavailable (for example private
// browsing or an E2E context). Logout/push cleanup must never wait on it.
return (await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null)) ?? null
}
export const getStoredPushSubscriptionId = (): string | null => {
if (!isBrowser()) return null
return localStorage.getItem(PUSH_SUBSCRIPTION_ID_STORAGE_KEY)
}
export const setStoredPushSubscriptionId = (id: string) => {
if (!isBrowser()) return
localStorage.setItem(PUSH_SUBSCRIPTION_ID_STORAGE_KEY, id)
}
export const clearStoredPushSubscriptionId = () => {
if (!isBrowser()) return
localStorage.removeItem(PUSH_SUBSCRIPTION_ID_STORAGE_KEY)
}
/**
* Tears down the browser-level PushSubscription for *this* device only.
* Does not talk to the server — pair with a call that deletes the matching
* server-side row (see services/push.ts unsubscribeCurrentDevicePush).
*/
export const unsubscribeBrowserPushManager = async (): Promise<void> => {
const registration = await getServiceWorkerRegistration()
if (!registration) return
try {
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
}
} catch {
// Best-effort: if the browser can't tear it down, the server-side
// delete still stops delivery, and a stale local subscription will
// self-correct next time syncSubscription() runs.
}
}
export const urlBase64ToUint8Array = (base64String: string) => {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
export const arrayBufferToBase64Url = (buffer: ArrayBuffer) => {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i += 1) {
binary += String.fromCharCode(bytes[i])
}
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export const getVapidPublicKey = () => process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY?.trim() ?? ''