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>
231 lines
6.3 KiB
TypeScript
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,
|
|
}
|
|
}
|