Skip to content
← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

FollowFork

starter-tanstack/app/ui/install-cta.tsx

159 lines · 4.2 KB · TypeScript
import { useCallback, useEffect, useState } from 'react'
import { IconDownload } from '../icons/download.tsx'

type DeferredPrompt = {
	prompt: () => Promise<void>
	userChoice?: Promise<unknown>
}

/**
 * Module-level capture so remounts cannot drop a preventDefault'd BIP
 * event (Chrome warns if preventDefault runs and prompt() never does).
 */
let capturedPrompt: DeferredPrompt | null = null
let bipListenerBound = false
const listeners = new Set<() => void>()

function bindBeforeInstallPrompt() {
	if (typeof window === 'undefined' || bipListenerBound) return
	bipListenerBound = true
	window.addEventListener('beforeinstallprompt', (event) => {
		const promptFn = (event as unknown as DeferredPrompt).prompt
		if (typeof promptFn !== 'function' || !document.querySelector('[data-install]')) {
			return
		}
		event.preventDefault()
		capturedPrompt = event as unknown as DeferredPrompt
		for (const listener of listeners) listener()
	})
	window.addEventListener('appinstalled', () => {
		capturedPrompt = null
		for (const listener of listeners) listener()
	})
}

async function promptCapturedInstall() {
	const event = capturedPrompt
	if (typeof event?.prompt !== 'function') return false
	capturedPrompt = null
	try {
		await event.prompt()
		if (event.userChoice) await event.userChoice
		return true
	} catch {
		return false
	}
}

/**
 * Header install control (reserved slot) + optional iOS A2HS overlay.
 */
export function InstallCta({ appName }: { appName: string }) {
	const [standalone, setStandalone] = useState(false)
	const [ios, setIos] = useState(false)
	const [hintDismissed, setHintDismissed] = useState(false)
	const [iosHintOpen, setIosHintOpen] = useState(false)
	const [canNative, setCanNative] = useState(false)
	const [clientReady, setClientReady] = useState(false)
	const hintKey = '__PACKAGE_ID__-install-hint-dismissed'

	const detect = useCallback(() => {
		try {
			setStandalone(
				(navigator as { standalone?: boolean }).standalone === true ||
					window.matchMedia(
						'(display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui)',
					).matches,
			)
		} catch {
			setStandalone(false)
		}
		setIos(
			/iphone|ipad|ipod/i.test(navigator.userAgent) ||
				(navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1),
		)
		try {
			setHintDismissed(localStorage.getItem(hintKey) !== null)
		} catch {
			/* ignore */
		}
		setCanNative(Boolean(capturedPrompt?.prompt))
	}, [])

	useEffect(() => {
		setClientReady(true)
		detect()
		bindBeforeInstallPrompt()
		const onChange = () => detect()
		listeners.add(onChange)
		return () => {
			listeners.delete(onChange)
		}
	}, [detect])

	const showIos = clientReady && !standalone && ios && !hintDismissed && !canNative
	const installAvailable = canNative || showIos
	const tip = `Install ${appName}`

	return (
		<span className="pak-install-root">
			<div
				className="pak-install-slot"
				data-install-wrap
				data-available={installAvailable ? 'true' : 'false'}
			>
				<button
					className="pak-btn pak-btn-icon"
					type="button"
					data-install
					data-tip={tip}
					aria-label={tip}
					aria-hidden={installAvailable ? undefined : true}
					tabIndex={installAvailable ? undefined : -1}
					onClick={async () => {
						if (capturedPrompt?.prompt) {
							await promptCapturedInstall()
							detect()
							return
						}
						if (showIos) setIosHintOpen((open) => !open)
					}}
				>
					<IconDownload size={20} />
				</button>
			</div>

			{showIos && iosHintOpen ? (
				<div
					className="pak-install-hint-overlay"
					data-install-hint
					role="dialog"
					aria-label="Add to Home Screen"
				>
					<div className="pak-card pak-stack pak-install-hint-card">
						<p className="pak-muted">
							Add <strong>{appName}</strong> to your Home Screen: tap <strong>Share</strong>, then{' '}
							<strong>Add to Home Screen</strong>.
						</p>
						<button
							className="pak-btn"
							type="button"
							data-install-hint-dismiss
							onClick={() => {
								try {
									localStorage.setItem(hintKey, String(Date.now()))
								} catch {
									/* ignore */
								}
								setHintDismissed(true)
								setIosHintOpen(false)
							}}
						>
							Dismiss
						</button>
					</div>
				</div>
			) : null}
		</span>
	)
}