Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
171 lines
5.5 KiB
TypeScript
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() ?? ''
|