From 041ad0e44ab941c1a3be2daa8e54a17904add5e2 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 23 Jul 2026 15:38:14 +0530 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20offline=20web=20push=20?= =?UTF-8?q?=E2=80=94=20service=20worker,=20subscribe=20flow,=20deep-link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications phase 2: a /push-sw.js service worker shows a system notification on push and, on click, focuses an open CRM tab (or opens one) and deep-links to the thread. usePushNotifications registers the SW, requests permission, subscribes with IIOS's VAPID key, and stores the subscription via the be-crm door. The topbar bell becomes a real enable/disable control (hidden when push is unsupported/demo). Dashboard listens for the SW's notif-click message + a ?thread= deep link. Co-Authored-By: Claude Opus 4.8 --- public/push-sw.js | 50 +++++++ src/components/dashboard/dashboard.tsx | 19 +++ .../dashboard/notification-bell.tsx | 74 ++++++++++ src/components/dashboard/topbar.tsx | 7 +- src/lib/push-notifications.ts | 138 ++++++++++++++++++ 5 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 public/push-sw.js create mode 100644 src/components/dashboard/notification-bell.tsx create mode 100644 src/lib/push-notifications.ts diff --git a/public/push-sw.js b/public/push-sw.js new file mode 100644 index 0000000..a5330f1 --- /dev/null +++ b/public/push-sw.js @@ -0,0 +1,50 @@ +/* Web Push service worker for CRM offline notifications. + * + * Receives push payloads from IIOS (WebPushDelivery), shows a system notification, and on click + * focuses an open CRM tab (or opens one) and posts the thread to deep-link to. The payload shape is + * IIOS's NotificationPayload: { title, body, data: { threadId, interactionId? } }. + * + * Served from /public at /push-sw.js → root scope ('/'), so it controls the whole app. + */ + +self.addEventListener("push", (event) => { + let payload = {}; + try { + payload = event.data ? event.data.json() : {}; + } catch { + payload = { title: "New notification", body: event.data ? event.data.text() : "" }; + } + const title = payload.title || "New message"; + const threadId = payload.data && payload.data.threadId; + event.waitUntil( + self.registration.showNotification(title, { + body: payload.body || "", + // Coalesce repeated pings for the same thread into one notification. + tag: threadId ? `thread:${threadId}` : undefined, + renotify: Boolean(threadId), + data: payload.data || {}, + icon: "/icons/i_bell.svg", + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const threadId = event.notification.data && event.notification.data.threadId; + event.waitUntil( + (async () => { + const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + // Prefer an already-open CRM tab: focus it and tell the app which thread to open. + for (const client of all) { + if ("focus" in client) { + await client.focus(); + client.postMessage({ type: "notif-click", threadId: threadId || null }); + return; + } + } + // No tab open — launch one deep-linked via the query string. + const url = threadId ? `/dashboard?thread=${encodeURIComponent(threadId)}` : "/dashboard"; + if (self.clients.openWindow) await self.clients.openWindow(url); + })(), + ); +}); diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index bd5842e..9071c1d 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -39,6 +39,25 @@ export function Dashboard() { // eslint-disable-next-line react-hooks/set-state-in-effect try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {} }, []); + + // Deep-link from a clicked push notification. Two paths from the service worker: + // - a tab was already open → it postMessages { type: 'notif-click', threadId } to focus here + // - no tab was open → it opens /dashboard?thread=, which we read once on mount + useEffect(() => { + try { + const t = new URLSearchParams(window.location.search).get("thread"); + if (t) { + navigateToConversation("messenger", t); + window.history.replaceState({}, "", window.location.pathname); + } + } catch {} + if (!("serviceWorker" in navigator)) return; + const onMessage = (e: MessageEvent) => { + if (e.data?.type === "notif-click" && e.data.threadId) navigateToConversation("messenger", e.data.threadId); + }; + navigator.serviceWorker.addEventListener("message", onMessage); + return () => navigator.serviceWorker.removeEventListener("message", onMessage); + }, []); function toggle() { setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; }); } diff --git a/src/components/dashboard/notification-bell.tsx b/src/components/dashboard/notification-bell.tsx new file mode 100644 index 0000000..b76973d --- /dev/null +++ b/src/components/dashboard/notification-bell.tsx @@ -0,0 +1,74 @@ +"use client"; + +// Topbar bell → offline-notification control. Click opens a small popover to enable/disable Web Push +// for this browser. The dot is lit when this browser is subscribed. Hidden entirely when push isn't +// available (demo mode, or a browser without ServiceWorker/PushManager). + +import { useState } from "react"; +import { Icon } from "./ui"; +import { usePushNotifications } from "@/lib/push-notifications"; + +export function NotificationBell() { + const push = usePushNotifications(); + const [open, setOpen] = useState(false); + + if (!push.supported) return null; + + const denied = push.permission === "denied"; + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+
Offline notifications
+

+ {push.subscribed + ? "You'll get push notifications for new direct messages and mentions, even when this tab is closed." + : "Get notified about direct messages and mentions when the CRM isn't open."} +

+ + {denied ? ( +

+ Notifications are blocked in your browser settings. Allow them for this site, then try again. +

+ ) : push.subscribed ? ( + + ) : ( + + )} + + {push.error &&

{push.error}

} +
+ + )} +
+ ); +} diff --git a/src/components/dashboard/topbar.tsx b/src/components/dashboard/topbar.tsx index 2fd027b..0fd3af8 100644 --- a/src/components/dashboard/topbar.tsx +++ b/src/components/dashboard/topbar.tsx @@ -4,8 +4,8 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { Sun, Moon, ChevronDown, LogOut } from "lucide-react"; import { useAuth } from "@abe-kap/appshell-sdk/react"; -import { Icon } from "./ui"; import { GlobalSearch } from "./global-search"; +import { NotificationBell } from "./notification-bell"; import { user } from "./account-data"; function initialsOf(name: string): string { @@ -40,10 +40,7 @@ export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme - +