From 3aaa12910a3aa5de7aee6022b6d9c67625f22ca4 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Tue, 21 Jul 2026 00:08:04 +0200 Subject: [PATCH] feat: iOS Safari install instructions (v0.65.1) beforeinstallprompt is Chromium-only by spec -- Safari (iOS and macOS) never fires it and has no programmatic install API at all, so the existing install banner silently did nothing there. Detects iOS Safari specifically and shows a static "tap Share, then Add to Home Screen" banner instead, dismissible and tracked separately from the Chromium banner's own dismissal state. No-ops once already installed (display-mode: standalone / navigator.standalone). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 +++ apps/web/components/pwa/install-prompt.tsx | 48 ++++++++++++++++++++-- apps/web/lib/changelog.ts | 9 +++- apps/web/package.json | 2 +- package.json | 2 +- 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0244898..cd15bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.65.1 — 2026-07-21 01:00 + +### Added +- The install banner now shows an iOS Safari-specific instructional variant ("Tap Share, then Add to Home Screen") instead of silently doing nothing — Safari never fires the event the regular Install button depends on, so it had no install guidance at all before this. + ## 0.65.0 — 2026-07-21 00:45 ### Added diff --git a/apps/web/components/pwa/install-prompt.tsx b/apps/web/components/pwa/install-prompt.tsx index f80c8f2..bd74222 100644 --- a/apps/web/components/pwa/install-prompt.tsx +++ b/apps/web/components/pwa/install-prompt.tsx @@ -1,22 +1,42 @@ "use client"; import { useEffect, useState } from "react"; -import { X, Download } from "lucide-react"; +import { X, Download, Share } from "lucide-react"; import { Button } from "@/components/ui/button"; const DISMISSED_KEY = "epicure:install-prompt-dismissed"; +const IOS_DISMISSED_KEY = "epicure:ios-install-hint-dismissed"; type BeforeInstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; }; -/** Only fires on Chromium-based browsers (Chrome/Edge/Android) — Safari and - * Firefox never dispatch beforeinstallprompt, so this banner simply never - * appears there, which is the correct behavior (no fallback UI to build). */ +function isStandalone(): boolean { + return window.matchMedia("(display-mode: standalone)").matches || (navigator as { standalone?: boolean }).standalone === true; +} + +/** iOS Safari has no beforeinstallprompt and no programmatic install API at + * all (Apple restriction, not a capability gap we can code around) — the + * only path is the user manually using the Share sheet, so the best we can + * do is tell them that. */ +function isIosSafari(): boolean { + const ua = navigator.userAgent; + const isIos = /iPad|iPhone|iPod/.test(ua) || (ua.includes("Macintosh") && navigator.maxTouchPoints > 1); + const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(ua); + return isIos && isSafari; +} + +/** Chromium (Chrome/Edge/Android) shows an "Install" button driven by the + * real beforeinstallprompt event; iOS Safari — which never fires it — gets + * a static instructional banner instead. Both are no-ops once the app is + * already installed (standalone display mode). */ export function InstallPrompt() { const [deferredEvent, setDeferredEvent] = useState(null); const [dismissed, setDismissed] = useState(() => typeof localStorage !== "undefined" && localStorage.getItem(DISMISSED_KEY) === "true"); + const [showIosHint, setShowIosHint] = useState( + () => typeof window !== "undefined" && isIosSafari() && !isStandalone() && localStorage.getItem(IOS_DISMISSED_KEY) !== "true" + ); useEffect(() => { function handler(e: Event) { @@ -32,6 +52,11 @@ export function InstallPrompt() { setDismissed(true); } + function dismissIosHint() { + localStorage.setItem(IOS_DISMISSED_KEY, "true"); + setShowIosHint(false); + } + async function install() { if (!deferredEvent) return; await deferredEvent.prompt(); @@ -39,6 +64,21 @@ export function InstallPrompt() { setDeferredEvent(null); } + if (showIosHint) { + return ( +
+ +
+

Install Epicure

+

Tap Share, then "Add to Home Screen".

+
+ +
+ ); + } + if (!deferredEvent || dismissed) return null; return ( diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index cd5ac4f..26e6f68 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.65.0"; +export const APP_VERSION = "0.65.1"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.65.1", + date: "2026-07-21 01:00", + added: [ + "The install banner now shows an iOS Safari-specific instructional variant (\"Tap Share, then Add to Home Screen\") instead of silently doing nothing — Safari never fires the event the regular Install button depends on, so it had no install guidance at all before this.", + ], + }, { version: "0.65.0", date: "2026-07-21 00:45", diff --git a/apps/web/package.json b/apps/web/package.json index b01eaeb..af55ae0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.65.0", + "version": "0.65.1", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index be0962c..9957714 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.65.0", + "version": "0.65.1", "private": true, "scripts": { "dev": "pnpm --filter web dev",