Files
craftvia/src/components/offline/offline-runtime-client.tsx
T

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>
)}
</>
);
}