feat: add cross-platform PWA install prompt
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
import { initials, cn } from "../lib/format";
|
import { initials, cn } from "../lib/format";
|
||||||
|
import { InstallAppPrompt } from "./InstallAppPrompt";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -131,6 +132,9 @@ export function AdminShell() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-extrabold">Admin</span>
|
<span className="text-sm font-extrabold">Admin</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-auto w-auto">
|
||||||
|
<InstallAppPrompt />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<Outlet key={location.pathname} />
|
<Outlet key={location.pathname} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
import { initials, cn } from "../lib/format";
|
import { initials, cn } from "../lib/format";
|
||||||
import { DemoSwitcher } from "./DemoSwitcher";
|
import { DemoSwitcher } from "./DemoSwitcher";
|
||||||
|
import { InstallAppPrompt } from "./InstallAppPrompt";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -164,6 +165,9 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-extrabold">AgendaMax</span>
|
<span className="text-sm font-extrabold">AgendaMax</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-auto w-auto">
|
||||||
|
<InstallAppPrompt />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<Outlet key={location.pathname} />
|
<Outlet key={location.pathname} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary w-full justify-start" onClick={() => setIosOpen(true)}>
|
||||||
|
<Download className="h-4 w-4" /> Instalar AgendaMax
|
||||||
|
</button>
|
||||||
|
<Modal open={iosOpen} onClose={() => setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio.">
|
||||||
|
<div className="safe-area-bottom">
|
||||||
|
<ol className="space-y-3 text-sm text-slate-600">
|
||||||
|
<li className="flex gap-3"><Share className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Toca <strong>Compartir</strong> en la barra de Safari.</span></li>
|
||||||
|
<li className="flex gap-3"><PlusSquare className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Elige <strong>Añadir a pantalla de inicio</strong> y confirma.</span></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <button type="button" className="btn btn-secondary w-full justify-start" onClick={() => void install()}><Download className="h-4 w-4" /> Instalar AgendaMax</button>;
|
||||||
|
}
|
||||||
@@ -281,6 +281,9 @@ body {
|
|||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
background: #fecaca;
|
background: #fecaca;
|
||||||
}
|
}
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.input,
|
.input,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed";
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
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<InstallPromptState>(() => {
|
||||||
|
if (typeof window === "undefined") return "unsupported";
|
||||||
|
if (isStandalone()) return "installed";
|
||||||
|
return isIOSSafari() ? "ios-instructions" : "unsupported";
|
||||||
|
});
|
||||||
|
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(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) };
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Sparkles, ArrowRight, Mail, Lock, AlertCircle, Wand2 } from "lucide-rea
|
|||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { Spinner } from "../components/ui";
|
import { Spinner } from "../components/ui";
|
||||||
|
import { InstallAppPrompt } from "../components/InstallAppPrompt";
|
||||||
|
|
||||||
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
||||||
|
|
||||||
@@ -156,6 +157,10 @@ export function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-4 max-w-sm">
|
||||||
|
<InstallAppPrompt />
|
||||||
|
</div>
|
||||||
|
|
||||||
{DEMO && (
|
{DEMO && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-5 flex items-center gap-2 text-xs font-medium text-slate-400">
|
<div className="mt-5 flex items-center gap-2 text-xs font-medium text-slate-400">
|
||||||
|
|||||||
Reference in New Issue
Block a user