@open-mercato/ui 0.6.7-develop.6749.1.6b54c56dfe → 0.6.7-develop.6751.1.ac823a3d26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  Generated lucide registry with 145 icons -> /home/runner/work/open-mercato/open-mercato/packages/ui/src/backend/icons/lucideRegistry.generated.tsx
2
- [build:ui] found 394 entry points
2
+ [build:ui] found 395 entry points
3
3
  [build:ui] built successfully
@@ -189,7 +189,7 @@ import {
189
189
  | `usePortalAppEvent(pattern, handler, deps?)` | Listen for portal SSE events by glob pattern (e.g., `'sales.order.*'`) |
190
190
  | `usePortalEventBridge()` | Establish singleton SSE connection — mount once in shell/layout |
191
191
  | `usePortalInjectedMenuItems(surfaceId)` | Load feature-gated menu items for portal nav surfaces: `{ items, isLoading }` |
192
- | `usePortalNotifications()` | Poll portal notifications: `{ notifications, unreadCount, hasNew, isLoading, refresh, markAsRead, dismiss, markAllRead }` |
192
+ | `usePortalNotifications()` | Load portal notifications with SSE-first refresh and an 8-second fallback until the portal event bridge reports healthy: `{ notifications, unreadCount, hasNew, isLoading, refresh, markAsRead, dismiss, markAllRead }` |
193
193
  | `usePortalDashboardWidgets(spotId)` | Load UI injection widgets (with `Widget` component) for a portal spot: `{ widgets, isLoading, error }` |
194
194
 
195
195
  ### Portal Components
@@ -0,0 +1,25 @@
1
+ const PORTAL_BRIDGE_STATUS_DOM_NAME = "om:portal-bridge:status";
2
+ function readPortalBridgeHealth() {
3
+ if (typeof window === "undefined") return void 0;
4
+ return window.__portalBridgeHealthy;
5
+ }
6
+ function publishPortalBridgeHealth(healthy) {
7
+ if (typeof window === "undefined") return;
8
+ window.__portalBridgeHealthy = healthy;
9
+ window.dispatchEvent(
10
+ new CustomEvent(PORTAL_BRIDGE_STATUS_DOM_NAME, {
11
+ detail: { healthy }
12
+ })
13
+ );
14
+ }
15
+ function clearPortalBridgeHealth() {
16
+ if (typeof window === "undefined") return;
17
+ delete window.__portalBridgeHealthy;
18
+ }
19
+ export {
20
+ PORTAL_BRIDGE_STATUS_DOM_NAME,
21
+ clearPortalBridgeHealth,
22
+ publishPortalBridgeHealth,
23
+ readPortalBridgeHealth
24
+ };
25
+ //# sourceMappingURL=portalBridgeStatus.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/portal/hooks/portalBridgeStatus.ts"],
4
+ "sourcesContent": ["export const PORTAL_BRIDGE_STATUS_DOM_NAME = 'om:portal-bridge:status'\n\nexport type PortalBridgeStatusDetail = {\n healthy: boolean\n}\n\ntype PortalBridgeWindow = Window & {\n __portalBridgeHealthy?: boolean\n}\n\nexport function readPortalBridgeHealth(): boolean | undefined {\n if (typeof window === 'undefined') return undefined\n return (window as PortalBridgeWindow).__portalBridgeHealthy\n}\n\nexport function publishPortalBridgeHealth(healthy: boolean): void {\n if (typeof window === 'undefined') return\n ;(window as PortalBridgeWindow).__portalBridgeHealthy = healthy\n window.dispatchEvent(\n new CustomEvent<PortalBridgeStatusDetail>(PORTAL_BRIDGE_STATUS_DOM_NAME, {\n detail: { healthy },\n }),\n )\n}\n\nexport function clearPortalBridgeHealth(): void {\n if (typeof window === 'undefined') return\n delete (window as PortalBridgeWindow).__portalBridgeHealthy\n}\n"],
5
+ "mappings": "AAAO,MAAM,gCAAgC;AAUtC,SAAS,yBAA8C;AAC5D,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,SAAQ,OAA8B;AACxC;AAEO,SAAS,0BAA0B,SAAwB;AAChE,MAAI,OAAO,WAAW,YAAa;AAClC,EAAC,OAA8B,wBAAwB;AACxD,SAAO;AAAA,IACL,IAAI,YAAsC,+BAA+B;AAAA,MACvE,QAAQ,EAAE,QAAQ;AAAA,IACpB,CAAC;AAAA,EACH;AACF;AAEO,SAAS,0BAAgC;AAC9C,MAAI,OAAO,WAAW,YAAa;AACnC,SAAQ,OAA8B;AACxC;",
6
+ "names": []
7
+ }
@@ -2,6 +2,7 @@
2
2
  import { useEffect, useRef } from "react";
3
3
  import { PORTAL_EVENT_DOM_NAME } from "./usePortalAppEvent.js";
4
4
  import { createLogger } from "@open-mercato/shared/lib/logger";
5
+ import { publishPortalBridgeHealth } from "./portalBridgeStatus.js";
5
6
  const logger = createLogger("ui").child({ component: "PortalEventBridge" });
6
7
  const PORTAL_SSE_ENDPOINT = "/api/customer_accounts/portal/events/stream";
7
8
  const HEARTBEAT_TIMEOUT = 45e3;
@@ -36,6 +37,7 @@ function usePortalEventBridge() {
36
37
  if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current);
37
38
  heartbeatTimer.current = setTimeout(() => {
38
39
  logger.warn("Heartbeat timeout \u2014 reconnecting");
40
+ publishPortalBridgeHealth(false);
39
41
  disconnect();
40
42
  scheduleReconnect();
41
43
  }, HEARTBEAT_TIMEOUT);
@@ -51,6 +53,7 @@ function usePortalEventBridge() {
51
53
  hasEverConnected.current = true;
52
54
  reconnectPending.current = false;
53
55
  reconnectAttempts.current = 0;
56
+ publishPortalBridgeHealth(true);
54
57
  resetHeartbeatTimer();
55
58
  if (shouldEmitReconnect) {
56
59
  window.dispatchEvent(
@@ -82,6 +85,7 @@ function usePortalEventBridge() {
82
85
  if (hasEverConnected.current) {
83
86
  reconnectPending.current = true;
84
87
  }
88
+ publishPortalBridgeHealth(false);
85
89
  disconnect();
86
90
  if (mounted) scheduleReconnect();
87
91
  };
@@ -89,6 +93,7 @@ function usePortalEventBridge() {
89
93
  if (hasEverConnected.current) {
90
94
  reconnectPending.current = true;
91
95
  }
96
+ publishPortalBridgeHealth(false);
92
97
  if (mounted) scheduleReconnect();
93
98
  }
94
99
  }
@@ -117,6 +122,7 @@ function usePortalEventBridge() {
117
122
  connect();
118
123
  return () => {
119
124
  mounted = false;
125
+ publishPortalBridgeHealth(false);
120
126
  disconnect();
121
127
  if (reconnectTimer.current) {
122
128
  clearTimeout(reconnectTimer.current);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/portal/hooks/usePortalEventBridge.ts"],
4
- "sourcesContent": ["\"use client\"\nimport { useEffect, useRef } from 'react'\nimport type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'\nimport { PORTAL_EVENT_DOM_NAME } from './usePortalAppEvent'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'PortalEventBridge' })\n\nconst PORTAL_SSE_ENDPOINT = '/api/customer_accounts/portal/events/stream'\nconst HEARTBEAT_TIMEOUT = 45_000\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\nconst DEDUP_WINDOW_MS = 500\nconst PORTAL_BRIDGE_RECONNECTED_EVENT_ID = 'om:portal-bridge:reconnected'\n\n/**\n * React hook that establishes a singleton SSE connection to the portal event bridge.\n *\n * Mount once in the portal shell/layout. Receives server-side events with\n * `portalBroadcast: true` and dispatches them as `om:portal-event` CustomEvents\n * on the window object for consumption by `usePortalAppEvent`.\n *\n * Uses customer auth (cookie-based JWT) instead of staff auth.\n *\n * @example\n * ```tsx\n * import { usePortalEventBridge } from '@open-mercato/ui/portal/hooks/usePortalEventBridge'\n *\n * function PortalShell({ children }) {\n * usePortalEventBridge()\n * return <div>{children}</div>\n * }\n * ```\n */\nexport function usePortalEventBridge(): void {\n const sourceRef = useRef<EventSource | null>(null)\n const reconnectAttempts = useRef(0)\n const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n const heartbeatTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n const recentEvents = useRef<Map<string, number>>(new Map())\n const hasEverConnected = useRef(false)\n const reconnectPending = useRef(false)\n\n useEffect(() => {\n let mounted = true\n\n function isDuplicate(eventPayload: AppEventPayload): boolean {\n const key = `${eventPayload.id}:${JSON.stringify(eventPayload.payload ?? {})}`\n const lastSeen = recentEvents.current.get(key)\n if (lastSeen && Date.now() - lastSeen < DEDUP_WINDOW_MS) return true\n recentEvents.current.set(key, Date.now())\n if (recentEvents.current.size > 100) {\n const now = Date.now()\n for (const [k, v] of recentEvents.current) {\n if (now - v > DEDUP_WINDOW_MS * 2) recentEvents.current.delete(k)\n }\n }\n return false\n }\n\n function resetHeartbeatTimer() {\n if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current)\n heartbeatTimer.current = setTimeout(() => {\n logger.warn('Heartbeat timeout \u2014 reconnecting')\n disconnect()\n scheduleReconnect()\n }, HEARTBEAT_TIMEOUT)\n }\n\n function connect() {\n if (!mounted) return\n if (sourceRef.current) return\n\n try {\n const source = new EventSource(PORTAL_SSE_ENDPOINT, { withCredentials: true })\n sourceRef.current = source\n\n source.onopen = () => {\n const shouldEmitReconnect = hasEverConnected.current && reconnectPending.current\n hasEverConnected.current = true\n reconnectPending.current = false\n reconnectAttempts.current = 0\n resetHeartbeatTimer()\n if (shouldEmitReconnect) {\n window.dispatchEvent(\n new CustomEvent(PORTAL_EVENT_DOM_NAME, {\n detail: {\n id: PORTAL_BRIDGE_RECONNECTED_EVENT_ID,\n payload: {},\n timestamp: Date.now(),\n organizationId: '',\n } satisfies AppEventPayload,\n }),\n )\n }\n }\n\n source.onmessage = (event) => {\n resetHeartbeatTimer()\n if (!event.data || event.data === ':heartbeat') return\n\n try {\n const parsed = JSON.parse(event.data) as AppEventPayload\n if (!parsed.id || typeof parsed.id !== 'string') return\n if (isDuplicate(parsed)) return\n\n window.dispatchEvent(\n new CustomEvent(PORTAL_EVENT_DOM_NAME, { detail: parsed }),\n )\n } catch {\n // Ignore malformed events\n }\n }\n\n source.onerror = () => {\n if (hasEverConnected.current) {\n reconnectPending.current = true\n }\n disconnect()\n if (mounted) scheduleReconnect()\n }\n } catch {\n if (hasEverConnected.current) {\n reconnectPending.current = true\n }\n if (mounted) scheduleReconnect()\n }\n }\n\n function disconnect() {\n if (sourceRef.current) {\n sourceRef.current.close()\n sourceRef.current = null\n }\n if (heartbeatTimer.current) {\n clearTimeout(heartbeatTimer.current)\n heartbeatTimer.current = null\n }\n }\n\n function scheduleReconnect() {\n if (reconnectTimer.current) return\n const delay = Math.min(\n RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts.current),\n RECONNECT_MAX_MS,\n )\n reconnectAttempts.current++\n reconnectTimer.current = setTimeout(() => {\n reconnectTimer.current = null\n connect()\n }, delay)\n }\n\n connect()\n\n return () => {\n mounted = false\n disconnect()\n if (reconnectTimer.current) {\n clearTimeout(reconnectTimer.current)\n reconnectTimer.current = null\n }\n }\n }, [])\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport { useEffect, useRef } from 'react'\nimport type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'\nimport { PORTAL_EVENT_DOM_NAME } from './usePortalAppEvent'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { publishPortalBridgeHealth } from './portalBridgeStatus'\n\nconst logger = createLogger('ui').child({ component: 'PortalEventBridge' })\n\nconst PORTAL_SSE_ENDPOINT = '/api/customer_accounts/portal/events/stream'\nconst HEARTBEAT_TIMEOUT = 45_000\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\nconst DEDUP_WINDOW_MS = 500\nconst PORTAL_BRIDGE_RECONNECTED_EVENT_ID = 'om:portal-bridge:reconnected'\n\n/**\n * React hook that establishes a singleton SSE connection to the portal event bridge.\n *\n * Mount once in the portal shell/layout. Receives server-side events with\n * `portalBroadcast: true` and dispatches them as `om:portal-event` CustomEvents\n * on the window object for consumption by `usePortalAppEvent`.\n *\n * Uses customer auth (cookie-based JWT) instead of staff auth.\n *\n * @example\n * ```tsx\n * import { usePortalEventBridge } from '@open-mercato/ui/portal/hooks/usePortalEventBridge'\n *\n * function PortalShell({ children }) {\n * usePortalEventBridge()\n * return <div>{children}</div>\n * }\n * ```\n */\nexport function usePortalEventBridge(): void {\n const sourceRef = useRef<EventSource | null>(null)\n const reconnectAttempts = useRef(0)\n const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n const heartbeatTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n const recentEvents = useRef<Map<string, number>>(new Map())\n const hasEverConnected = useRef(false)\n const reconnectPending = useRef(false)\n\n useEffect(() => {\n let mounted = true\n\n function isDuplicate(eventPayload: AppEventPayload): boolean {\n const key = `${eventPayload.id}:${JSON.stringify(eventPayload.payload ?? {})}`\n const lastSeen = recentEvents.current.get(key)\n if (lastSeen && Date.now() - lastSeen < DEDUP_WINDOW_MS) return true\n recentEvents.current.set(key, Date.now())\n if (recentEvents.current.size > 100) {\n const now = Date.now()\n for (const [k, v] of recentEvents.current) {\n if (now - v > DEDUP_WINDOW_MS * 2) recentEvents.current.delete(k)\n }\n }\n return false\n }\n\n function resetHeartbeatTimer() {\n if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current)\n heartbeatTimer.current = setTimeout(() => {\n logger.warn('Heartbeat timeout \u2014 reconnecting')\n publishPortalBridgeHealth(false)\n disconnect()\n scheduleReconnect()\n }, HEARTBEAT_TIMEOUT)\n }\n\n function connect() {\n if (!mounted) return\n if (sourceRef.current) return\n\n try {\n const source = new EventSource(PORTAL_SSE_ENDPOINT, { withCredentials: true })\n sourceRef.current = source\n\n source.onopen = () => {\n const shouldEmitReconnect = hasEverConnected.current && reconnectPending.current\n hasEverConnected.current = true\n reconnectPending.current = false\n reconnectAttempts.current = 0\n publishPortalBridgeHealth(true)\n resetHeartbeatTimer()\n if (shouldEmitReconnect) {\n window.dispatchEvent(\n new CustomEvent(PORTAL_EVENT_DOM_NAME, {\n detail: {\n id: PORTAL_BRIDGE_RECONNECTED_EVENT_ID,\n payload: {},\n timestamp: Date.now(),\n organizationId: '',\n } satisfies AppEventPayload,\n }),\n )\n }\n }\n\n source.onmessage = (event) => {\n resetHeartbeatTimer()\n if (!event.data || event.data === ':heartbeat') return\n\n try {\n const parsed = JSON.parse(event.data) as AppEventPayload\n if (!parsed.id || typeof parsed.id !== 'string') return\n if (isDuplicate(parsed)) return\n\n window.dispatchEvent(\n new CustomEvent(PORTAL_EVENT_DOM_NAME, { detail: parsed }),\n )\n } catch {\n // Ignore malformed events\n }\n }\n\n source.onerror = () => {\n if (hasEverConnected.current) {\n reconnectPending.current = true\n }\n publishPortalBridgeHealth(false)\n disconnect()\n if (mounted) scheduleReconnect()\n }\n } catch {\n if (hasEverConnected.current) {\n reconnectPending.current = true\n }\n publishPortalBridgeHealth(false)\n if (mounted) scheduleReconnect()\n }\n }\n\n function disconnect() {\n if (sourceRef.current) {\n sourceRef.current.close()\n sourceRef.current = null\n }\n if (heartbeatTimer.current) {\n clearTimeout(heartbeatTimer.current)\n heartbeatTimer.current = null\n }\n }\n\n function scheduleReconnect() {\n if (reconnectTimer.current) return\n const delay = Math.min(\n RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts.current),\n RECONNECT_MAX_MS,\n )\n reconnectAttempts.current++\n reconnectTimer.current = setTimeout(() => {\n reconnectTimer.current = null\n connect()\n }, delay)\n }\n\n connect()\n\n return () => {\n mounted = false\n publishPortalBridgeHealth(false)\n disconnect()\n if (reconnectTimer.current) {\n clearTimeout(reconnectTimer.current)\n reconnectTimer.current = null\n }\n }\n }, [])\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,6 +1,10 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
3
  import { apiCall } from "../../backend/utils/apiCall.js";
4
+ import {
5
+ PORTAL_BRIDGE_STATUS_DOM_NAME,
6
+ readPortalBridgeHealth
7
+ } from "./portalBridgeStatus.js";
4
8
  const POLL_INTERVAL = 8e3;
5
9
  const BASE = "/api/customer_accounts/portal/notifications";
6
10
  async function fetchJson(url, init) {
@@ -37,21 +41,55 @@ function usePortalNotifications() {
37
41
  }
38
42
  setIsLoading(false);
39
43
  }, []);
44
+ const [usePolling, setUsePolling] = React.useState(() => {
45
+ if (typeof window === "undefined" || !("EventSource" in window)) {
46
+ return true;
47
+ }
48
+ return readPortalBridgeHealth() !== true;
49
+ });
50
+ React.useEffect(() => {
51
+ if (typeof window === "undefined" || !("EventSource" in window)) {
52
+ return;
53
+ }
54
+ const handleStatusChange = (event) => {
55
+ const detail = event.detail;
56
+ if (detail && typeof detail.healthy === "boolean") {
57
+ setUsePolling(!detail.healthy);
58
+ if (!detail.healthy) {
59
+ fetchAll();
60
+ }
61
+ }
62
+ };
63
+ window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange);
64
+ return () => {
65
+ window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange);
66
+ };
67
+ }, [fetchAll]);
40
68
  React.useEffect(() => {
41
69
  fetchAll();
70
+ }, [fetchAll]);
71
+ React.useEffect(() => {
72
+ if (!usePolling) return;
42
73
  const interval = setInterval(fetchAll, POLL_INTERVAL);
43
74
  return () => clearInterval(interval);
44
- }, [fetchAll]);
75
+ }, [fetchAll, usePolling]);
45
76
  React.useEffect(() => {
46
- const handler = (e) => {
47
- const detail = e.detail;
48
- if (detail?.id === "notifications.notification.created" || detail?.id === "notifications.notification.batch_created") {
77
+ const handler = (event) => {
78
+ const detail = event.detail;
79
+ if (detail?.id === "notifications.notification.created" || detail?.id === "notifications.notification.batch_created" || detail?.id === "om:portal-bridge:reconnected") {
49
80
  fetchAll();
50
81
  }
51
82
  };
52
83
  window.addEventListener("om:portal-event", handler);
53
84
  return () => window.removeEventListener("om:portal-event", handler);
54
85
  }, [fetchAll]);
86
+ React.useEffect(() => {
87
+ const onFocus = () => {
88
+ fetchAll();
89
+ };
90
+ window.addEventListener("focus", onFocus);
91
+ return () => window.removeEventListener("focus", onFocus);
92
+ }, [fetchAll]);
55
93
  const markAsRead = React.useCallback(async (id) => {
56
94
  await fetchJson(`${BASE}/${id}/read`, { method: "PUT" });
57
95
  setNotifications(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/portal/hooks/usePortalNotifications.ts"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { apiCall } from '../../backend/utils/apiCall'\n\nexport type UsePortalNotificationsResult = {\n notifications: NotificationDto[]\n unreadCount: number\n hasNew: boolean\n isLoading: boolean\n refresh: () => void\n markAsRead: (id: string) => Promise<void>\n dismiss: (id: string) => Promise<void>\n markAllRead: () => Promise<void>\n}\n\nconst POLL_INTERVAL = 8000\nconst BASE = '/api/customer_accounts/portal/notifications'\n\nasync function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null> {\n try {\n const { ok, result } = await apiCall<T>(url, init)\n if (!ok) return null\n return result\n } catch {\n return null\n }\n}\n\n/**\n * Portal notification hook \u2014 polls customer notification endpoints.\n *\n * Fetches notifications from `/api/customer_accounts/portal/notifications`\n * and unread count from `.../unread-count`. Polls every 8 seconds.\n *\n * Also listens for portal SSE events (`notifications.notification.created`)\n * to trigger immediate refresh.\n */\nexport function usePortalNotifications(): UsePortalNotificationsResult {\n const [notifications, setNotifications] = React.useState<NotificationDto[]>([])\n const [unreadCount, setUnreadCount] = React.useState(0)\n const [hasNew, setHasNew] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const lastIdRef = React.useRef<string | null>(null)\n\n const fetchAll = React.useCallback(async () => {\n const [listData, countData] = await Promise.all([\n fetchJson<{ ok: boolean; items: NotificationDto[] }>(`${BASE}?pageSize=50`),\n fetchJson<{ ok: boolean; unreadCount: number }>(`${BASE}/unread-count`),\n ])\n\n if (listData?.ok && listData.items) {\n const items = listData.items\n if (lastIdRef.current && items.length > 0 && items[0].id !== lastIdRef.current) {\n setHasNew(true)\n setTimeout(() => setHasNew(false), 3000)\n }\n if (items.length > 0) lastIdRef.current = items[0].id\n setNotifications(items)\n }\n\n if (countData?.ok) {\n setUnreadCount(countData.unreadCount)\n }\n\n setIsLoading(false)\n }, [])\n\n // Poll\n React.useEffect(() => {\n fetchAll()\n const interval = setInterval(fetchAll, POLL_INTERVAL)\n return () => clearInterval(interval)\n }, [fetchAll])\n\n // Listen for portal SSE notification events\n React.useEffect(() => {\n const handler = (e: Event) => {\n const detail = (e as CustomEvent).detail\n if (detail?.id === 'notifications.notification.created' || detail?.id === 'notifications.notification.batch_created') {\n fetchAll()\n }\n }\n window.addEventListener('om:portal-event', handler)\n return () => window.removeEventListener('om:portal-event', handler)\n }, [fetchAll])\n\n const markAsRead = React.useCallback(async (id: string) => {\n await fetchJson(`${BASE}/${id}/read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) => (n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n)),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }, [])\n\n const dismiss = React.useCallback(async (id: string) => {\n await fetchJson(`${BASE}/${id}/dismiss`, { method: 'PUT' })\n setNotifications((prev) => prev.filter((n) => n.id !== id))\n setUnreadCount((prev) => {\n const wasDismissedUnread = notifications.find((n) => n.id === id)?.status === 'unread'\n return wasDismissedUnread ? Math.max(0, prev - 1) : prev\n })\n }, [notifications])\n\n const markAllRead = React.useCallback(async () => {\n await fetchJson(`${BASE}/mark-all-read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) => (n.status === 'unread' ? { ...n, status: 'read', readAt: new Date().toISOString() } : n)),\n )\n setUnreadCount(0)\n }, [])\n\n const refresh = React.useCallback(() => { fetchAll() }, [fetchAll])\n\n return { notifications, unreadCount, hasNew, isLoading, refresh, markAsRead, dismiss, markAllRead }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { apiCall } from '../../backend/utils/apiCall'\nimport {\n PORTAL_BRIDGE_STATUS_DOM_NAME,\n readPortalBridgeHealth,\n type PortalBridgeStatusDetail,\n} from './portalBridgeStatus'\n\nexport type UsePortalNotificationsResult = {\n notifications: NotificationDto[]\n unreadCount: number\n hasNew: boolean\n isLoading: boolean\n refresh: () => void\n markAsRead: (id: string) => Promise<void>\n dismiss: (id: string) => Promise<void>\n markAllRead: () => Promise<void>\n}\n\nconst POLL_INTERVAL = 8000\nconst BASE = '/api/customer_accounts/portal/notifications'\n\nasync function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null> {\n try {\n const { ok, result } = await apiCall<T>(url, init)\n if (!ok) return null\n return result\n } catch {\n return null\n }\n}\n\n/**\n * Portal notification hook with SSE-first delivery and polling fallback.\n *\n * Performs an initial list/count reconciliation, refreshes on notification,\n * reconnect, and focus events, and polls every 8 seconds until the portal\n * event bridge explicitly reports a healthy connection.\n */\nexport function usePortalNotifications(): UsePortalNotificationsResult {\n const [notifications, setNotifications] = React.useState<NotificationDto[]>([])\n const [unreadCount, setUnreadCount] = React.useState(0)\n const [hasNew, setHasNew] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const lastIdRef = React.useRef<string | null>(null)\n\n const fetchAll = React.useCallback(async () => {\n const [listData, countData] = await Promise.all([\n fetchJson<{ ok: boolean; items: NotificationDto[] }>(`${BASE}?pageSize=50`),\n fetchJson<{ ok: boolean; unreadCount: number }>(`${BASE}/unread-count`),\n ])\n\n if (listData?.ok && listData.items) {\n const items = listData.items\n if (lastIdRef.current && items.length > 0 && items[0].id !== lastIdRef.current) {\n setHasNew(true)\n setTimeout(() => setHasNew(false), 3000)\n }\n if (items.length > 0) lastIdRef.current = items[0].id\n setNotifications(items)\n }\n\n if (countData?.ok) {\n setUnreadCount(countData.unreadCount)\n }\n\n setIsLoading(false)\n }, [])\n\n const [usePolling, setUsePolling] = React.useState(() => {\n if (typeof window === 'undefined' || !('EventSource' in window)) {\n return true\n }\n return readPortalBridgeHealth() !== true\n })\n\n React.useEffect(() => {\n if (typeof window === 'undefined' || !('EventSource' in window)) {\n return\n }\n const handleStatusChange = (event: Event) => {\n const detail = (event as CustomEvent<PortalBridgeStatusDetail>).detail\n if (detail && typeof detail.healthy === 'boolean') {\n setUsePolling(!detail.healthy)\n if (!detail.healthy) {\n fetchAll()\n }\n }\n }\n window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)\n return () => {\n window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)\n }\n }, [fetchAll])\n\n React.useEffect(() => {\n fetchAll()\n }, [fetchAll])\n\n React.useEffect(() => {\n if (!usePolling) return\n const interval = setInterval(fetchAll, POLL_INTERVAL)\n return () => clearInterval(interval)\n }, [fetchAll, usePolling])\n\n React.useEffect(() => {\n const handler = (event: Event) => {\n const detail = (event as CustomEvent<{ id?: string }>).detail\n if (\n detail?.id === 'notifications.notification.created' ||\n detail?.id === 'notifications.notification.batch_created' ||\n detail?.id === 'om:portal-bridge:reconnected'\n ) {\n fetchAll()\n }\n }\n window.addEventListener('om:portal-event', handler)\n return () => window.removeEventListener('om:portal-event', handler)\n }, [fetchAll])\n\n React.useEffect(() => {\n const onFocus = () => {\n fetchAll()\n }\n window.addEventListener('focus', onFocus)\n return () => window.removeEventListener('focus', onFocus)\n }, [fetchAll])\n\n const markAsRead = React.useCallback(async (id: string) => {\n await fetchJson(`${BASE}/${id}/read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) => (n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n)),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }, [])\n\n const dismiss = React.useCallback(async (id: string) => {\n await fetchJson(`${BASE}/${id}/dismiss`, { method: 'PUT' })\n setNotifications((prev) => prev.filter((n) => n.id !== id))\n setUnreadCount((prev) => {\n const wasDismissedUnread = notifications.find((n) => n.id === id)?.status === 'unread'\n return wasDismissedUnread ? Math.max(0, prev - 1) : prev\n })\n }, [notifications])\n\n const markAllRead = React.useCallback(async () => {\n await fetchJson(`${BASE}/mark-all-read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) => (n.status === 'unread' ? { ...n, status: 'read', readAt: new Date().toISOString() } : n)),\n )\n setUnreadCount(0)\n }, [])\n\n const refresh = React.useCallback(() => { fetchAll() }, [fetchAll])\n\n return { notifications, unreadCount, hasNew, isLoading, refresh, markAsRead, dismiss, markAllRead }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.7-develop.6749.1.6b54c56dfe",
3
+ "version": "0.6.7-develop.6751.1.ac823a3d26",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.7-develop.6749.1.6b54c56dfe",
158
+ "@open-mercato/shared": "0.6.7-develop.6751.1.ac823a3d26",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.7-develop.6749.1.6b54c56dfe",
164
+ "@open-mercato/shared": "0.6.7-develop.6751.1.ac823a3d26",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -0,0 +1,66 @@
1
+ /** @jest-environment jsdom */
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { usePortalEventBridge } from '../usePortalEventBridge'
4
+ import {
5
+ clearPortalBridgeHealth,
6
+ PORTAL_BRIDGE_STATUS_DOM_NAME,
7
+ readPortalBridgeHealth,
8
+ type PortalBridgeStatusDetail,
9
+ } from '../portalBridgeStatus'
10
+
11
+ class EventSourceMock {
12
+ static instances: EventSourceMock[] = []
13
+
14
+ onopen: ((event: Event) => void) | null = null
15
+ onmessage: ((event: MessageEvent) => void) | null = null
16
+ onerror: ((event: Event) => void) | null = null
17
+ close = jest.fn()
18
+
19
+ constructor() {
20
+ EventSourceMock.instances.push(this)
21
+ }
22
+ }
23
+
24
+ describe('usePortalEventBridge health lifecycle', () => {
25
+ const originalEventSource = globalThis.window?.EventSource
26
+
27
+ beforeEach(() => {
28
+ jest.clearAllMocks()
29
+ EventSourceMock.instances = []
30
+ clearPortalBridgeHealth()
31
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = EventSourceMock as unknown as typeof EventSource
32
+ })
33
+
34
+ afterEach(() => {
35
+ clearPortalBridgeHealth()
36
+ if (typeof originalEventSource === 'undefined') {
37
+ delete (window as unknown as { EventSource?: typeof EventSource }).EventSource
38
+ } else {
39
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = originalEventSource
40
+ }
41
+ })
42
+
43
+ it('publishes healthy on open and unhealthy when the bridge unmounts', () => {
44
+ const statuses: boolean[] = []
45
+ const handleStatus = (event: Event) => {
46
+ statuses.push((event as CustomEvent<PortalBridgeStatusDetail>).detail.healthy)
47
+ }
48
+ window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatus)
49
+
50
+ const { unmount } = renderHook(() => usePortalEventBridge())
51
+ const source = EventSourceMock.instances[0]
52
+ expect(source).toBeDefined()
53
+
54
+ act(() => {
55
+ source.onopen?.(new Event('open'))
56
+ })
57
+ expect(readPortalBridgeHealth()).toBe(true)
58
+
59
+ unmount()
60
+
61
+ expect(source.close).toHaveBeenCalledTimes(1)
62
+ expect(readPortalBridgeHealth()).toBe(false)
63
+ expect(statuses).toEqual([true, false])
64
+ window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatus)
65
+ })
66
+ })
@@ -0,0 +1,155 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { renderHook, act, waitFor } from '@testing-library/react'
4
+ import { usePortalNotifications } from '../usePortalNotifications'
5
+ import {
6
+ clearPortalBridgeHealth,
7
+ publishPortalBridgeHealth,
8
+ } from '../portalBridgeStatus'
9
+
10
+ const apiCallMock = jest.fn()
11
+
12
+ jest.mock('../../../backend/utils/apiCall', () => ({
13
+ apiCall: (...args: unknown[]) => apiCallMock(...args),
14
+ }))
15
+
16
+ describe('usePortalNotifications strategy', () => {
17
+ const originalEventSource = globalThis.window?.EventSource
18
+ let setIntervalSpy: jest.SpyInstance
19
+
20
+ beforeEach(() => {
21
+ jest.clearAllMocks()
22
+ apiCallMock.mockResolvedValue({
23
+ ok: true,
24
+ result: { ok: true, items: [], unreadCount: 0 },
25
+ })
26
+ setIntervalSpy = jest.spyOn(global, 'setInterval')
27
+ clearPortalBridgeHealth()
28
+ })
29
+
30
+ afterEach(() => {
31
+ setIntervalSpy.mockRestore()
32
+ if (typeof originalEventSource === 'undefined') {
33
+ delete (window as unknown as { EventSource?: typeof EventSource }).EventSource
34
+ } else {
35
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = originalEventSource
36
+ }
37
+ })
38
+
39
+ it('keeps polling until an available EventSource bridge reports healthy', async () => {
40
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
41
+ return {} as EventSource
42
+ } as unknown as typeof EventSource
43
+
44
+ const { result } = renderHook(() => usePortalNotifications())
45
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
46
+
47
+ expect(apiCallMock).toHaveBeenCalled()
48
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 8000)
49
+ })
50
+
51
+ it('uses SSE strategy without polling when the bridge is explicitly healthy', async () => {
52
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
53
+ return {} as EventSource
54
+ } as unknown as typeof EventSource
55
+ publishPortalBridgeHealth(true)
56
+
57
+ const { result } = renderHook(() => usePortalNotifications())
58
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
59
+
60
+ expect(apiCallMock).toHaveBeenCalled()
61
+ expect(setIntervalSpy).not.toHaveBeenCalledWith(expect.any(Function), 8000)
62
+ })
63
+
64
+ it('falls back to polling strategy when EventSource is unavailable', async () => {
65
+ delete (window as unknown as { EventSource?: typeof EventSource }).EventSource
66
+
67
+ const { result } = renderHook(() => usePortalNotifications())
68
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
69
+
70
+ expect(apiCallMock).toHaveBeenCalled()
71
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 8000)
72
+ })
73
+
74
+ it('activates polling when SSE is explicitly marked unhealthy', async () => {
75
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
76
+ return {} as EventSource
77
+ } as unknown as typeof EventSource
78
+
79
+ publishPortalBridgeHealth(false)
80
+
81
+ const { result } = renderHook(() => usePortalNotifications())
82
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
83
+
84
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 8000)
85
+ })
86
+
87
+ it('switches to polling fallback dynamically when status becomes unhealthy', async () => {
88
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
89
+ return {} as EventSource
90
+ } as unknown as typeof EventSource
91
+ publishPortalBridgeHealth(true)
92
+
93
+ const { result } = renderHook(() => usePortalNotifications())
94
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
95
+ expect(apiCallMock).toHaveBeenCalledTimes(2)
96
+
97
+ expect(setIntervalSpy).not.toHaveBeenCalledWith(expect.any(Function), 8000)
98
+
99
+ act(() => {
100
+ publishPortalBridgeHealth(false)
101
+ })
102
+
103
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 8000)
104
+ await waitFor(() => expect(apiCallMock).toHaveBeenCalledTimes(4))
105
+ })
106
+
107
+ it('performs one reconciliation for an unhealthy-to-reconnected sequence', async () => {
108
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
109
+ return {} as EventSource
110
+ } as unknown as typeof EventSource
111
+ publishPortalBridgeHealth(true)
112
+
113
+ const { result } = renderHook(() => usePortalNotifications())
114
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
115
+ expect(apiCallMock).toHaveBeenCalledTimes(2)
116
+
117
+ act(() => {
118
+ publishPortalBridgeHealth(false)
119
+ })
120
+ await waitFor(() => expect(apiCallMock).toHaveBeenCalledTimes(4))
121
+
122
+ act(() => {
123
+ publishPortalBridgeHealth(true)
124
+ window.dispatchEvent(
125
+ new CustomEvent('om:portal-event', {
126
+ detail: {
127
+ id: 'om:portal-bridge:reconnected',
128
+ payload: {},
129
+ },
130
+ })
131
+ )
132
+ })
133
+
134
+ await waitFor(() => expect(apiCallMock).toHaveBeenCalledTimes(6))
135
+ })
136
+
137
+ it('reconciles notifications when the portal window regains focus', async () => {
138
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
139
+ return {} as EventSource
140
+ } as unknown as typeof EventSource
141
+
142
+ const { result } = renderHook(() => usePortalNotifications())
143
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
144
+ expect(apiCallMock).toHaveBeenCalledTimes(2)
145
+
146
+ await act(async () => {
147
+ window.dispatchEvent(new Event('focus'))
148
+ await new Promise<void>((resolve) => {
149
+ setTimeout(resolve, 0)
150
+ })
151
+ })
152
+
153
+ await waitFor(() => expect(apiCallMock).toHaveBeenCalledTimes(4))
154
+ })
155
+ })
@@ -0,0 +1,29 @@
1
+ export const PORTAL_BRIDGE_STATUS_DOM_NAME = 'om:portal-bridge:status'
2
+
3
+ export type PortalBridgeStatusDetail = {
4
+ healthy: boolean
5
+ }
6
+
7
+ type PortalBridgeWindow = Window & {
8
+ __portalBridgeHealthy?: boolean
9
+ }
10
+
11
+ export function readPortalBridgeHealth(): boolean | undefined {
12
+ if (typeof window === 'undefined') return undefined
13
+ return (window as PortalBridgeWindow).__portalBridgeHealthy
14
+ }
15
+
16
+ export function publishPortalBridgeHealth(healthy: boolean): void {
17
+ if (typeof window === 'undefined') return
18
+ ;(window as PortalBridgeWindow).__portalBridgeHealthy = healthy
19
+ window.dispatchEvent(
20
+ new CustomEvent<PortalBridgeStatusDetail>(PORTAL_BRIDGE_STATUS_DOM_NAME, {
21
+ detail: { healthy },
22
+ }),
23
+ )
24
+ }
25
+
26
+ export function clearPortalBridgeHealth(): void {
27
+ if (typeof window === 'undefined') return
28
+ delete (window as PortalBridgeWindow).__portalBridgeHealthy
29
+ }
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
3
3
  import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
4
4
  import { PORTAL_EVENT_DOM_NAME } from './usePortalAppEvent'
5
5
  import { createLogger } from '@open-mercato/shared/lib/logger'
6
+ import { publishPortalBridgeHealth } from './portalBridgeStatus'
6
7
 
7
8
  const logger = createLogger('ui').child({ component: 'PortalEventBridge' })
8
9
 
@@ -62,6 +63,7 @@ export function usePortalEventBridge(): void {
62
63
  if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current)
63
64
  heartbeatTimer.current = setTimeout(() => {
64
65
  logger.warn('Heartbeat timeout — reconnecting')
66
+ publishPortalBridgeHealth(false)
65
67
  disconnect()
66
68
  scheduleReconnect()
67
69
  }, HEARTBEAT_TIMEOUT)
@@ -80,6 +82,7 @@ export function usePortalEventBridge(): void {
80
82
  hasEverConnected.current = true
81
83
  reconnectPending.current = false
82
84
  reconnectAttempts.current = 0
85
+ publishPortalBridgeHealth(true)
83
86
  resetHeartbeatTimer()
84
87
  if (shouldEmitReconnect) {
85
88
  window.dispatchEvent(
@@ -116,6 +119,7 @@ export function usePortalEventBridge(): void {
116
119
  if (hasEverConnected.current) {
117
120
  reconnectPending.current = true
118
121
  }
122
+ publishPortalBridgeHealth(false)
119
123
  disconnect()
120
124
  if (mounted) scheduleReconnect()
121
125
  }
@@ -123,6 +127,7 @@ export function usePortalEventBridge(): void {
123
127
  if (hasEverConnected.current) {
124
128
  reconnectPending.current = true
125
129
  }
130
+ publishPortalBridgeHealth(false)
126
131
  if (mounted) scheduleReconnect()
127
132
  }
128
133
  }
@@ -155,6 +160,7 @@ export function usePortalEventBridge(): void {
155
160
 
156
161
  return () => {
157
162
  mounted = false
163
+ publishPortalBridgeHealth(false)
158
164
  disconnect()
159
165
  if (reconnectTimer.current) {
160
166
  clearTimeout(reconnectTimer.current)
@@ -2,6 +2,11 @@
2
2
  import * as React from 'react'
3
3
  import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
4
4
  import { apiCall } from '../../backend/utils/apiCall'
5
+ import {
6
+ PORTAL_BRIDGE_STATUS_DOM_NAME,
7
+ readPortalBridgeHealth,
8
+ type PortalBridgeStatusDetail,
9
+ } from './portalBridgeStatus'
5
10
 
6
11
  export type UsePortalNotificationsResult = {
7
12
  notifications: NotificationDto[]
@@ -28,13 +33,11 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null>
28
33
  }
29
34
 
30
35
  /**
31
- * Portal notification hook polls customer notification endpoints.
36
+ * Portal notification hook with SSE-first delivery and polling fallback.
32
37
  *
33
- * Fetches notifications from `/api/customer_accounts/portal/notifications`
34
- * and unread count from `.../unread-count`. Polls every 8 seconds.
35
- *
36
- * Also listens for portal SSE events (`notifications.notification.created`)
37
- * to trigger immediate refresh.
38
+ * Performs an initial list/count reconciliation, refreshes on notification,
39
+ * reconnect, and focus events, and polls every 8 seconds until the portal
40
+ * event bridge explicitly reports a healthy connection.
38
41
  */
39
42
  export function usePortalNotifications(): UsePortalNotificationsResult {
40
43
  const [notifications, setNotifications] = React.useState<NotificationDto[]>([])
@@ -66,18 +69,50 @@ export function usePortalNotifications(): UsePortalNotificationsResult {
66
69
  setIsLoading(false)
67
70
  }, [])
68
71
 
69
- // Poll
72
+ const [usePolling, setUsePolling] = React.useState(() => {
73
+ if (typeof window === 'undefined' || !('EventSource' in window)) {
74
+ return true
75
+ }
76
+ return readPortalBridgeHealth() !== true
77
+ })
78
+
79
+ React.useEffect(() => {
80
+ if (typeof window === 'undefined' || !('EventSource' in window)) {
81
+ return
82
+ }
83
+ const handleStatusChange = (event: Event) => {
84
+ const detail = (event as CustomEvent<PortalBridgeStatusDetail>).detail
85
+ if (detail && typeof detail.healthy === 'boolean') {
86
+ setUsePolling(!detail.healthy)
87
+ if (!detail.healthy) {
88
+ fetchAll()
89
+ }
90
+ }
91
+ }
92
+ window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)
93
+ return () => {
94
+ window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)
95
+ }
96
+ }, [fetchAll])
97
+
70
98
  React.useEffect(() => {
71
99
  fetchAll()
100
+ }, [fetchAll])
101
+
102
+ React.useEffect(() => {
103
+ if (!usePolling) return
72
104
  const interval = setInterval(fetchAll, POLL_INTERVAL)
73
105
  return () => clearInterval(interval)
74
- }, [fetchAll])
106
+ }, [fetchAll, usePolling])
75
107
 
76
- // Listen for portal SSE notification events
77
108
  React.useEffect(() => {
78
- const handler = (e: Event) => {
79
- const detail = (e as CustomEvent).detail
80
- if (detail?.id === 'notifications.notification.created' || detail?.id === 'notifications.notification.batch_created') {
109
+ const handler = (event: Event) => {
110
+ const detail = (event as CustomEvent<{ id?: string }>).detail
111
+ if (
112
+ detail?.id === 'notifications.notification.created' ||
113
+ detail?.id === 'notifications.notification.batch_created' ||
114
+ detail?.id === 'om:portal-bridge:reconnected'
115
+ ) {
81
116
  fetchAll()
82
117
  }
83
118
  }
@@ -85,6 +120,14 @@ export function usePortalNotifications(): UsePortalNotificationsResult {
85
120
  return () => window.removeEventListener('om:portal-event', handler)
86
121
  }, [fetchAll])
87
122
 
123
+ React.useEffect(() => {
124
+ const onFocus = () => {
125
+ fetchAll()
126
+ }
127
+ window.addEventListener('focus', onFocus)
128
+ return () => window.removeEventListener('focus', onFocus)
129
+ }, [fetchAll])
130
+
88
131
  const markAsRead = React.useCallback(async (id: string) => {
89
132
  await fetchJson(`${BASE}/${id}/read`, { method: 'PUT' })
90
133
  setNotifications((prev) =>