089d77aa68
Same-origin sub-PWA for Tuxedo field workers, fully isolated from the main Route Commerce PWA. - public/manifest-field.json: Tuxedo-branded manifest, start_url=/water, scope=/water, themeColor=#14532d, no Route Commerce branding - public/sw-field.js: scoped SW with field-shell-v1 + field-data-v1 caches (never touches main app's rc-shell-v* / rc-data-v*). Skips any request outside /water scope. Two-cache strategy: shell cache-first + /api/water stale-while-revalidate. - src/app/water/layout.tsx: child layout overrides root metadata (manifest, themeColor, applicationName, appleWebApp.title) and mounts the field SW registration + install prompt. - src/components/pwa/FieldSWRegistration.tsx: registers /sw-field.js with scope=/water. Module-scoped registered flag prevents strict- mode double-registration. - src/components/pwa/FieldInstallPrompt.tsx: sibling of InstallPrompt, forest-green #1a4d2e theming, fires after 4s dwell. Captures display-mode:standalone AND navigator.standalone for iOS. No conflict with the main app PWA: PWAInstallPrompt is mounted only under /admin/layout.tsx, so /water routes only see FieldInstallPrompt. Tuxedo-only by design (matches existing /water single-tenant hardcode).
169 lines
5.2 KiB
TypeScript
169 lines
5.2 KiB
TypeScript
/**
|
|
* FieldInstallPrompt — install prompt for the worker sub-PWA.
|
|
*
|
|
* Cycle 6 sibling of `InstallPrompt` (mounted from the root layout).
|
|
* This one fires only on /water routes and uses the field-PWA
|
|
* branding (forest green icon, "Water Log — Tuxedo") so the prompt
|
|
* that surfaces matches the manifest that will be installed.
|
|
*
|
|
* Same `BeforeInstallPromptEvent` capture pattern as InstallPrompt;
|
|
* split into a separate component file because the worker surface
|
|
* has its own theming + copy.
|
|
*/
|
|
"use client";
|
|
|
|
import { useEffect, useSyncExternalStore, useCallback, useReducer } from "react";
|
|
|
|
interface BeforeInstallPromptEvent extends Event {
|
|
prompt(): Promise<void>;
|
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
|
}
|
|
|
|
type PromptState = {
|
|
deferred: BeforeInstallPromptEvent | null;
|
|
visible: boolean;
|
|
};
|
|
|
|
type PromptAction =
|
|
| { type: "captured"; event: BeforeInstallPromptEvent }
|
|
| { type: "show" }
|
|
| { type: "hide" }
|
|
| { type: "dismissed" }
|
|
| { type: "reset" };
|
|
|
|
function promptReducer(state: PromptState, action: PromptAction): PromptState {
|
|
switch (action.type) {
|
|
case "captured":
|
|
return { deferred: action.event, visible: false };
|
|
case "show":
|
|
return state.deferred ? { ...state, visible: true } : state;
|
|
case "hide":
|
|
return { deferred: null, visible: false };
|
|
case "dismissed":
|
|
return { ...state, visible: false };
|
|
case "reset":
|
|
return { deferred: null, visible: false };
|
|
}
|
|
}
|
|
|
|
const INITIAL_PROMPT_STATE: PromptState = { deferred: null, visible: false };
|
|
|
|
export function FieldInstallPrompt() {
|
|
const [prompt, dispatch] = useReducer(promptReducer, INITIAL_PROMPT_STATE);
|
|
const isInstalled = useSyncExternalStore(
|
|
() => () => {},
|
|
() =>
|
|
typeof window !== "undefined" &&
|
|
(window.matchMedia("(display-mode: standalone)").matches ||
|
|
// iOS Safari uses navigator.standalone for the home-screen PWA
|
|
// launch. Cover both.
|
|
(typeof (navigator as Navigator & { standalone?: boolean }).standalone ===
|
|
"boolean" &&
|
|
(navigator as Navigator & { standalone?: boolean }).standalone === true)),
|
|
() => false,
|
|
);
|
|
const mounted = useSyncExternalStore(
|
|
() => () => {},
|
|
() => true,
|
|
() => false,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isInstalled) return;
|
|
if (typeof window === "undefined") return;
|
|
|
|
const handleBeforeInstall = (e: Event) => {
|
|
e.preventDefault();
|
|
dispatch({ type: "captured", event: e as BeforeInstallPromptEvent });
|
|
// Show the field prompt sooner than the main one — workers
|
|
// know they want this installed on day one.
|
|
window.setTimeout(() => {
|
|
dispatch({ type: "show" });
|
|
}, 4000);
|
|
};
|
|
|
|
const handleAppInstalled = () => {
|
|
dispatch({ type: "reset" });
|
|
};
|
|
|
|
window.addEventListener("beforeinstallprompt", handleBeforeInstall);
|
|
window.addEventListener("appinstalled", handleAppInstalled);
|
|
|
|
return () => {
|
|
window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
|
|
window.removeEventListener("appinstalled", handleAppInstalled);
|
|
};
|
|
}, [isInstalled]);
|
|
|
|
const handleInstall = useCallback(async () => {
|
|
const deferred = prompt.deferred;
|
|
if (!deferred) return;
|
|
|
|
await deferred.prompt();
|
|
const { outcome } = await deferred.userChoice;
|
|
void outcome;
|
|
dispatch({ type: "reset" });
|
|
}, [prompt.deferred]);
|
|
|
|
const handleDismiss = useCallback(() => {
|
|
dispatch({ type: "dismissed" });
|
|
try {
|
|
sessionStorage.setItem("field-pwa-install-dismissed", "true");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
if (!mounted || !prompt.visible || isInstalled || !prompt.deferred) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-white rounded-2xl shadow-2xl border border-[#1a4d2e]/30 p-4 z-50">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-12 h-12 bg-[#1a4d2e] rounded-xl flex items-center justify-center shrink-0">
|
|
<svg
|
|
className="w-6 h-6 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-[#1a4d2e]">
|
|
Install Water Log
|
|
</h3>
|
|
<p className="text-sm text-[rgba(60,60,67,0.65)] mt-1">
|
|
One tap to a full-screen PIN entry from your home screen.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 mt-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleDismiss}
|
|
className="flex-1 px-4 py-2 text-sm font-medium text-[rgba(60,60,67,0.55)] hover:text-[rgba(60,60,67,0.85)] transition-colors"
|
|
>
|
|
Not now
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleInstall}
|
|
className="flex-1 px-4 bg-[#1a4d2e] text-white rounded-lg text-sm font-medium hover:bg-[#14432a] py-2 transition-colors"
|
|
>
|
|
Install
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default FieldInstallPrompt;
|