From 0238dff5b1291be3910ed013abd5f265314244f4 Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Mon, 27 Jul 2026 16:28:54 -0600 Subject: [PATCH] feat: add cross-platform PWA install prompt --- src/components/AdminShell.tsx | 4 ++ src/components/AppShell.tsx | 4 ++ src/components/InstallAppPrompt.tsx | 29 +++++++++++++ src/index.css | 3 ++ src/lib/useInstallPrompt.ts | 65 +++++++++++++++++++++++++++++ src/pages/LoginPage.tsx | 5 +++ 6 files changed, 110 insertions(+) create mode 100644 src/components/InstallAppPrompt.tsx create mode 100644 src/lib/useInstallPrompt.ts diff --git a/src/components/AdminShell.tsx b/src/components/AdminShell.tsx index dc27427..4f8ad16 100644 --- a/src/components/AdminShell.tsx +++ b/src/components/AdminShell.tsx @@ -12,6 +12,7 @@ import { import { useNavigate } from "react-router-dom"; import { useAuth } from "../lib/auth"; import { initials, cn } from "../lib/format"; +import { InstallAppPrompt } from "./InstallAppPrompt"; interface NavItem { to: string; @@ -131,6 +132,9 @@ export function AdminShell() { Admin +
+ +
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index ffd95a0..e763e41 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -19,6 +19,7 @@ import { import { useAuth } from "../lib/auth"; import { initials, cn } from "../lib/format"; import { DemoSwitcher } from "./DemoSwitcher"; +import { InstallAppPrompt } from "./InstallAppPrompt"; interface NavItem { to: string; @@ -164,6 +165,9 @@ export function AppShell() { AgendaMax +
+ +
diff --git a/src/components/InstallAppPrompt.tsx b/src/components/InstallAppPrompt.tsx new file mode 100644 index 0000000..9133381 --- /dev/null +++ b/src/components/InstallAppPrompt.tsx @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { Download, PlusSquare, Share } from "lucide-react"; +import { Modal } from "./Modal"; +import { useInstallPrompt } from "../lib/useInstallPrompt"; + +export function InstallAppPrompt() { + const { state, install } = useInstallPrompt(); + const [iosOpen, setIosOpen] = useState(false); + + if (state === "unsupported" || state === "installed") return null; + if (state === "ios-instructions") { + return ( + <> + + setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio."> +
+
    +
  1. Toca Compartir en la barra de Safari.
  2. +
  3. Elige Añadir a pantalla de inicio y confirma.
  4. +
+
+
+ + ); + } + return ; +} diff --git a/src/index.css b/src/index.css index 094a1a9..9769647 100644 --- a/src/index.css +++ b/src/index.css @@ -281,6 +281,9 @@ body { .btn-danger:hover:not(:disabled) { background: #fecaca; } +.safe-area-bottom { + padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); +} @layer components { .input, diff --git a/src/lib/useInstallPrompt.ts b/src/lib/useInstallPrompt.ts new file mode 100644 index 0000000..57513a2 --- /dev/null +++ b/src/lib/useInstallPrompt.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; + +export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed"; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; +} + +function isStandalone() { + return window.matchMedia("(display-mode: standalone)").matches || + ("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone)); +} + +function isIOSSafari() { + const ua = navigator.userAgent; + const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua); +} + +export function useInstallPrompt() { + const [state, setState] = useState(() => { + if (typeof window === "undefined") return "unsupported"; + if (isStandalone()) return "installed"; + return isIOSSafari() ? "ios-instructions" : "unsupported"; + }); + const [deferred, setDeferred] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (isStandalone()) { + setState("installed"); + return; + } + + const onBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); + setDeferred(event as BeforeInstallPromptEvent); + setState("available"); + }; + const onInstalled = () => { + setDeferred(null); + setState("installed"); + }; + + window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); + window.addEventListener("appinstalled", onInstalled); + return () => { + window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); + window.removeEventListener("appinstalled", onInstalled); + }; + }, []); + + const install = async () => { + if (!deferred) return; + const event = deferred; + setDeferred(null); + await event.prompt(); + const choice = await event.userChoice; + if (choice.outcome === "accepted") setState("installed"); + else setDismissed(true); + }; + + return { state: dismissed ? "unsupported" : state, install, dismiss: () => setDismissed(true) }; +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 549a9d2..39f8405 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -4,6 +4,7 @@ import { Sparkles, ArrowRight, Mail, Lock, AlertCircle, Wand2 } from "lucide-rea import { useAuth } from "../lib/auth"; import { api } from "../lib/api"; import { Spinner } from "../components/ui"; +import { InstallAppPrompt } from "../components/InstallAppPrompt"; const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"; @@ -156,6 +157,10 @@ export function LoginPage() { +
+ +
+ {DEMO && ( <>