@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.
- package/.turbo/turbo-build.log +1 -1
- package/agentic/standalone-guide.md +1 -1
- package/dist/portal/hooks/portalBridgeStatus.js +25 -0
- package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
- package/dist/portal/hooks/usePortalEventBridge.js +6 -0
- package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
- package/dist/portal/hooks/usePortalNotifications.js +42 -4
- package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
- package/package.json +3 -3
- package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
- package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
- package/src/portal/hooks/portalBridgeStatus.ts +29 -0
- package/src/portal/hooks/usePortalEventBridge.ts +6 -0
- package/src/portal/hooks/usePortalNotifications.ts +55 -12
package/.turbo/turbo-build.log
CHANGED
|
@@ -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()` |
|
|
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": ";AACA,SAAS,WAAW,cAAc;AAElC,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;
|
|
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 = (
|
|
47
|
-
const 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
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;AAEvB,SAAS,eAAe;
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
36
|
+
* Portal notification hook with SSE-first delivery and polling fallback.
|
|
32
37
|
*
|
|
33
|
-
*
|
|
34
|
-
* and
|
|
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
|
-
|
|
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 = (
|
|
79
|
-
const detail = (
|
|
80
|
-
if (
|
|
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) =>
|