Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS Safari's own (non-standard, but the only signal it exposes).
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
|
||||
* service worker criteria are met — we capture and defer it (the spec
|
||||
* requires preventDefault() to control exactly when the native prompt
|
||||
* shows) and trigger it from this button instead of an unsolicited popup.
|
||||
* iOS Safari never fires that event at all, so it gets manual
|
||||
* "Add to Home Screen" instructions instead — there's no programmatic
|
||||
* install API on that platform.
|
||||
*/
|
||||
export function InstallAppButton() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installed, setInstalled] = useState(() => isStandalone());
|
||||
const [showIosInstructions, setShowIosInstructions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
const onInstalled = () => {
|
||||
setInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (installed) {
|
||||
return <p className="text-muted text-sm">NextChair is installed on this device.</p>;
|
||||
}
|
||||
|
||||
if (deferredPrompt) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" /> Install App
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isIos()) {
|
||||
return showIosInstructions ? (
|
||||
<p className="text-muted text-sm">
|
||||
Tap the Share icon in Safari, then choose “Add to Home Screen”.
|
||||
</p>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowIosInstructions(true)}>
|
||||
<Download className="h-4 w-4" aria-hidden="true" /> Install App
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return <p className="text-muted text-sm">Installing isn't available in this browser yet.</p>;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Registers the service worker once, app-wide, and surfaces the
|
||||
* update-available handshake public/sw.js sets up: a new SW version
|
||||
* installs and sits `waiting` rather than taking over immediately, so an
|
||||
* open tab isn't silently switched to a new app version mid-session. This
|
||||
* component detects that waiting worker and lets the user opt into the
|
||||
* refresh instead.
|
||||
*/
|
||||
export function ServiceWorkerManager() {
|
||||
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
// An update was already downloaded (e.g. by another tab) before this page loaded.
|
||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||
setWaitingWorker(registration.waiting);
|
||||
}
|
||||
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const installing = registration.installing;
|
||||
if (!installing) return;
|
||||
installing.addEventListener("statechange", () => {
|
||||
// Only a real update — an installing worker with no existing
|
||||
// controller is just this tab's first-ever SW install, not an update.
|
||||
if (installing.state === "installed" && navigator.serviceWorker.controller) {
|
||||
setWaitingWorker(installing);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort — offline/install/update support degrades gracefully without it.
|
||||
});
|
||||
|
||||
let reloaded = false;
|
||||
const onControllerChange = () => {
|
||||
if (reloaded) return;
|
||||
reloaded = true;
|
||||
window.location.reload();
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
|
||||
return () => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
|
||||
}, []);
|
||||
|
||||
if (!waitingWorker || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-jumbo bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
|
||||
>
|
||||
<span>A new version is available.</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold whitespace-nowrap underline"
|
||||
onClick={() => waitingWorker.postMessage({ type: "SKIP_WAITING" })}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
className="text-white/60"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user