95 lines
3.5 KiB
TypeScript
95 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { RefreshCw } from "lucide-react";
|
|
import { configureOffline, startSyncLoop } from "@/lib/offline/outbox";
|
|
import { SyncBadge } from "./sync-badge";
|
|
|
|
const SW_URL = "/sw.js";
|
|
const SW_DEV_FLAG = "craftvia.sw";
|
|
const UPDATE_CHECK_MS = 30 * 60_000;
|
|
|
|
/** In development the SW is opt-in (localStorage craftvia.sw = "1"), otherwise HMR chunks would be cached. */
|
|
function swEnabled(): boolean {
|
|
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return false;
|
|
if (process.env.NODE_ENV === "production") return true;
|
|
try {
|
|
return localStorage.getItem(SW_DEV_FLAG) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function OfflineRuntimeClient({ tenantId, userId, maxDays }: { tenantId: string; userId: string; maxDays: number }) {
|
|
const t = useTranslations("offline.update");
|
|
const [waiting, setWaiting] = useState<ServiceWorker | null>(null);
|
|
const reloading = useRef(false);
|
|
|
|
// outbox context + sync loop
|
|
useEffect(() => {
|
|
let stop: (() => void) | undefined;
|
|
let cancelled = false;
|
|
configureOffline({ tenantId, userId }, { maxDays }).then(() => {
|
|
if (!cancelled) stop = startSyncLoop();
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
stop?.();
|
|
};
|
|
}, [tenantId, userId, maxDays]);
|
|
|
|
// service worker registration + update flow
|
|
useEffect(() => {
|
|
if (!swEnabled()) return;
|
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
const onControllerChange = () => {
|
|
if (!reloading.current) return;
|
|
reloading.current = false;
|
|
window.location.reload();
|
|
};
|
|
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
|
|
navigator.serviceWorker
|
|
.register(SW_URL, { scope: "/", updateViaCache: "none" })
|
|
.then((reg) => {
|
|
if (reg.waiting && navigator.serviceWorker.controller) setWaiting(reg.waiting);
|
|
reg.addEventListener("updatefound", () => {
|
|
const installing = reg.installing;
|
|
installing?.addEventListener("statechange", () => {
|
|
if (installing.state === "installed" && navigator.serviceWorker.controller) setWaiting(installing);
|
|
});
|
|
});
|
|
// refresh the cached offline page with the current build and the signed-in session
|
|
(reg.active ?? reg.installing ?? reg.waiting)?.postMessage({ type: "PRECACHE_OFFLINE" });
|
|
timer = setInterval(() => void reg.update().catch(() => undefined), UPDATE_CHECK_MS);
|
|
})
|
|
.catch(() => undefined);
|
|
return () => {
|
|
navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
|
|
if (timer) clearInterval(timer);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<SyncBadge />
|
|
{waiting && (
|
|
<div role="status" aria-live="polite" className="fixed inset-x-3 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-40 mx-auto flex max-w-xl items-center gap-3 rounded-xl border bg-card p-3 shadow-card">
|
|
<RefreshCw className="size-5 shrink-0 text-primary" aria-hidden />
|
|
<p className="flex-1 text-[14px] font-semibold">{t("available")}</p>
|
|
<button
|
|
type="button"
|
|
className="inline-flex min-h-12 items-center rounded-xl bg-cta px-4 text-[15px] font-semibold text-cta-foreground"
|
|
onClick={() => {
|
|
reloading.current = true;
|
|
waiting.postMessage({ type: "SKIP_WAITING" });
|
|
}}
|
|
>
|
|
{t("reload")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|