// 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 userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> } const visible = ref(false) const mode = ref('offer') const canNativeInstall = ref(false) const platform = ref(detectPlatform()) let deferredPrompt: BeforeInstallPromptEvent | null = null let delayTimer: ReturnType | 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 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(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) { 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, } }