Files
hedena_gold/apps/user-app/src/composables/usePwaInstall.ts
T
mahmoodandCursor c438ab2159 feat(brand): rebrand to Hedena Gold and bring main user-app chrome
Rename the brand, ship Hedena logos, persist login OTP text in the SMS list, and port home/nav/auth/theme polish from main while keeping expert customers intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 16:40:54 +03:30

231 lines
6.3 KiB
TypeScript

// Brand-first PWA install offer (see docs/superpowers/specs/2026-10-15-pwa-install-prompt-design.md).
// Shared module state so App.vue and InstallPromptModal share one listener / one clock.
import { computed, ref, watch, type Ref } from 'vue'
import type { Router } from 'vue-router'
import { updateGateAvailable } from './useAppUpdate'
const STORAGE_KEY = 'pwaInstall'
const REOFFER_MS = 7 * 24 * 60 * 60 * 1000
const INITIAL_DELAY_MS = 4000
export type PwaGuidePlatform = 'ios' | 'android'
export type PwaInstallMode = 'offer' | 'guide'
interface Stored {
dismissedAt: number | null
installed: boolean
}
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
const visible = ref(false)
const mode = ref<PwaInstallMode>('offer')
const canNativeInstall = ref(false)
const platform = ref<PwaGuidePlatform>(detectPlatform())
let deferredPrompt: BeforeInstallPromptEvent | null = null
let delayTimer: ReturnType<typeof setTimeout> | undefined
let delayElapsed = false
let bootstrapped = false
let storageOk = true
/** When true, van-dialog `@closed` must not write dismissedAt (route/gate hide). */
let skipPersistOnClose = false
function isStandalone(): boolean {
if (typeof window === 'undefined') return false
const mq = window.matchMedia?.('(display-mode: standalone)')?.matches
const iosStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true
return Boolean(mq || iosStandalone)
}
function detectPlatform(): PwaGuidePlatform {
if (typeof navigator === 'undefined') return 'android'
const ua = navigator.userAgent || ''
const iOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
return iOS ? 'ios' : 'android'
}
function load(): Stored {
const fallback: Stored = { dismissedAt: null, installed: false }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as Partial<Stored>
return {
dismissedAt: typeof parsed.dismissedAt === 'number' ? parsed.dismissedAt : null,
installed: parsed.installed === true,
}
} catch {
storageOk = false
return fallback
}
}
function persist(value: Stored) {
if (!storageOk) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
} catch {
storageOk = false
}
}
const stored = ref<Stored>(load())
function syncInstalledFromEnvironment() {
if (!isStandalone()) return
if (stored.value.installed) return
stored.value = { ...stored.value, installed: true }
persist(stored.value)
}
function eligibilityCore(): boolean {
if (!storageOk) return false
syncInstalledFromEnvironment()
if (stored.value.installed || isStandalone()) return false
const dismissedAt = stored.value.dismissedAt
if (dismissedAt != null && Date.now() - dismissedAt < REOFFER_MS) return false
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return false
return true
}
const shouldShowPrompt = computed(() => eligibilityCore())
function closeModal(opts?: { silent?: boolean }) {
skipPersistOnClose = opts?.silent === true
visible.value = false
mode.value = 'offer'
}
function dismiss() {
stored.value = { ...stored.value, dismissedAt: Date.now() }
persist(stored.value)
closeModal({ silent: true })
}
function markInstalled() {
stored.value = { dismissedAt: stored.value.dismissedAt, installed: true }
persist(stored.value)
deferredPrompt = null
canNativeInstall.value = false
closeModal({ silent: true })
}
/** Overlay / ESC close from van-dialog — start the 7-day clock unless silent. */
function onDialogClosed() {
if (skipPersistOnClose) {
skipPersistOnClose = false
mode.value = 'offer'
return
}
if (!stored.value.installed) {
stored.value = { ...stored.value, dismissedAt: Date.now() }
persist(stored.value)
}
mode.value = 'offer'
}
function openGuide() {
mode.value = 'guide'
}
async function promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!deferredPrompt) return 'unavailable'
const event = deferredPrompt
deferredPrompt = null
canNativeInstall.value = false
try {
await event.prompt()
const { outcome } = await event.userChoice
if (outcome === 'accepted') {
markInstalled()
return 'accepted'
}
return 'dismissed'
} catch {
return 'dismissed'
}
}
async function onPrimaryCta() {
if (canNativeInstall.value && deferredPrompt) {
await promptInstall()
return
}
openGuide()
}
function tryOpen(isFullPage: boolean, updateGateOpen: boolean) {
if (!delayElapsed) return
if (isFullPage || updateGateOpen) {
if (visible.value) closeModal({ silent: true })
return
}
if (!eligibilityCore()) return
if (visible.value) return
mode.value = 'offer'
visible.value = true
}
function onBeforeInstallPrompt(e: Event) {
e.preventDefault()
deferredPrompt = e as BeforeInstallPromptEvent
canNativeInstall.value = true
}
function onAppInstalled() {
markInstalled()
}
function bootstrapAutoPrompt(router: Router, updateAvailable: Ref<unknown>) {
if (bootstrapped || typeof window === 'undefined') return
bootstrapped = true
platform.value = detectPlatform()
syncInstalledFromEnvironment()
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt)
window.addEventListener('appinstalled', onAppInstalled)
const isFullPage = () => router.currentRoute.value.meta?.layout === 'FullPage'
const updateGateOpen = () => Boolean(updateAvailable.value)
delayTimer = setTimeout(() => {
delayElapsed = true
tryOpen(isFullPage(), updateGateOpen())
}, INITIAL_DELAY_MS)
watch(
() => [router.currentRoute.value.fullPath, router.currentRoute.value.meta?.layout, updateAvailable.value] as const,
() => tryOpen(isFullPage(), updateGateOpen()),
)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') tryOpen(isFullPage(), updateGateOpen())
})
}
export function usePwaInstall() {
function start(router: Router) {
bootstrapAutoPrompt(router, updateGateAvailable)
}
return {
visible,
mode,
platform,
canNativeInstall,
shouldShowPrompt,
start,
dismiss,
markInstalled,
openGuide,
promptInstall,
onPrimaryCta,
closeModal,
onDialogClosed,
}
}