@open-mercato/ui 0.4.5-develop-6bdcebbece → 0.4.5-develop-986cfd8c37
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/AGENTS.md +8 -0
- package/dist/backend/AppShell.js +395 -134
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +232 -21
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +214 -94
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/injection/InjectionSpot.js +74 -4
- package/dist/backend/injection/InjectionSpot.js.map +2 -2
- package/dist/backend/injection/SseEventIndicator.js +16 -0
- package/dist/backend/injection/SseEventIndicator.js.map +7 -0
- package/dist/backend/injection/WidgetSharedState.js +49 -0
- package/dist/backend/injection/WidgetSharedState.js.map +7 -0
- package/dist/backend/injection/eventBridge.js +105 -0
- package/dist/backend/injection/eventBridge.js.map +7 -0
- package/dist/backend/injection/mergeMenuItems.js +43 -0
- package/dist/backend/injection/mergeMenuItems.js.map +7 -0
- package/dist/backend/injection/resolveInjectedIcon.js +23 -0
- package/dist/backend/injection/resolveInjectedIcon.js.map +7 -0
- package/dist/backend/injection/spotIds.js +40 -1
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/injection/useAppEvent.js +35 -0
- package/dist/backend/injection/useAppEvent.js.map +7 -0
- package/dist/backend/injection/useInjectedMenuItems.js +92 -0
- package/dist/backend/injection/useInjectedMenuItems.js.map +7 -0
- package/dist/backend/injection/useInjectionDataWidgets.js +36 -0
- package/dist/backend/injection/useInjectionDataWidgets.js.map +7 -0
- package/dist/backend/injection/useOperationProgress.js +64 -0
- package/dist/backend/injection/useOperationProgress.js.map +7 -0
- package/dist/backend/injection/useWidgetSharedState.js +26 -0
- package/dist/backend/injection/useWidgetSharedState.js.map +7 -0
- package/dist/backend/section-page/SectionNav.js +22 -2
- package/dist/backend/section-page/SectionNav.js.map +2 -2
- package/dist/backend/utils/api.js +9 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/package.json +2 -2
- package/src/backend/AGENTS.md +50 -0
- package/src/backend/AppShell.tsx +317 -30
- package/src/backend/CrudForm.tsx +238 -21
- package/src/backend/ProfileDropdown.tsx +199 -78
- package/src/backend/injection/InjectionSpot.tsx +118 -16
- package/src/backend/injection/SseEventIndicator.tsx +24 -0
- package/src/backend/injection/WidgetSharedState.ts +58 -0
- package/src/backend/injection/eventBridge.ts +134 -0
- package/src/backend/injection/mergeMenuItems.ts +71 -0
- package/src/backend/injection/resolveInjectedIcon.tsx +30 -0
- package/src/backend/injection/spotIds.ts +38 -0
- package/src/backend/injection/useAppEvent.ts +76 -0
- package/src/backend/injection/useInjectedMenuItems.ts +125 -0
- package/src/backend/injection/useInjectionDataWidgets.ts +41 -0
- package/src/backend/injection/useOperationProgress.ts +105 -0
- package/src/backend/injection/useWidgetSharedState.ts +28 -0
- package/src/backend/section-page/SectionNav.tsx +22 -1
- package/src/backend/utils/api.ts +14 -5
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
|
|
4
|
+
import { APP_EVENT_DOM_NAME } from './useAppEvent'
|
|
5
|
+
|
|
6
|
+
const SSE_ENDPOINT = '/api/events/stream'
|
|
7
|
+
const HEARTBEAT_TIMEOUT = 45_000 // Expect heartbeat every 30s, allow 45s grace
|
|
8
|
+
const RECONNECT_BASE_MS = 1_000
|
|
9
|
+
const RECONNECT_MAX_MS = 30_000
|
|
10
|
+
const DEDUP_WINDOW_MS = 500
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* React hook that establishes a singleton SSE connection to the event bridge.
|
|
14
|
+
*
|
|
15
|
+
* Mount once in the app shell/layout. Receives server-side events with
|
|
16
|
+
* `clientBroadcast: true` and dispatches them as `om:event` CustomEvents
|
|
17
|
+
* on the window object for consumption by `useAppEvent`.
|
|
18
|
+
*
|
|
19
|
+
* Features:
|
|
20
|
+
* - Auto-reconnect with exponential backoff
|
|
21
|
+
* - Deduplication within 500ms window
|
|
22
|
+
* - Heartbeat-based liveness detection
|
|
23
|
+
* - Singleton connection per browser tab
|
|
24
|
+
*/
|
|
25
|
+
export function useEventBridge(): void {
|
|
26
|
+
const sourceRef = useRef<EventSource | null>(null)
|
|
27
|
+
const reconnectAttempts = useRef(0)
|
|
28
|
+
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
29
|
+
const heartbeatTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
30
|
+
const recentEvents = useRef<Map<string, number>>(new Map())
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let mounted = true
|
|
34
|
+
|
|
35
|
+
function isDuplicate(eventPayload: AppEventPayload): boolean {
|
|
36
|
+
const key = `${eventPayload.id}:${JSON.stringify(eventPayload.payload ?? {})}`
|
|
37
|
+
const lastSeen = recentEvents.current.get(key)
|
|
38
|
+
if (lastSeen && Date.now() - lastSeen < DEDUP_WINDOW_MS) return true
|
|
39
|
+
recentEvents.current.set(key, Date.now())
|
|
40
|
+
// Prune old entries
|
|
41
|
+
if (recentEvents.current.size > 100) {
|
|
42
|
+
const now = Date.now()
|
|
43
|
+
for (const [k, v] of recentEvents.current) {
|
|
44
|
+
if (now - v > DEDUP_WINDOW_MS * 2) recentEvents.current.delete(k)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resetHeartbeatTimer() {
|
|
51
|
+
if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current)
|
|
52
|
+
heartbeatTimer.current = setTimeout(() => {
|
|
53
|
+
console.warn('[EventBridge] Heartbeat timeout — reconnecting')
|
|
54
|
+
disconnect()
|
|
55
|
+
scheduleReconnect()
|
|
56
|
+
}, HEARTBEAT_TIMEOUT)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function connect() {
|
|
60
|
+
if (!mounted) return
|
|
61
|
+
if (sourceRef.current) return
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const source = new EventSource(SSE_ENDPOINT, { withCredentials: true })
|
|
65
|
+
sourceRef.current = source
|
|
66
|
+
|
|
67
|
+
source.onopen = () => {
|
|
68
|
+
reconnectAttempts.current = 0
|
|
69
|
+
resetHeartbeatTimer()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
source.onmessage = (event) => {
|
|
73
|
+
resetHeartbeatTimer()
|
|
74
|
+
if (!event.data || event.data === ':heartbeat') return
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(event.data) as AppEventPayload
|
|
78
|
+
if (!parsed.id || typeof parsed.id !== 'string') return
|
|
79
|
+
|
|
80
|
+
if (isDuplicate(parsed)) return
|
|
81
|
+
|
|
82
|
+
window.dispatchEvent(
|
|
83
|
+
new CustomEvent(APP_EVENT_DOM_NAME, { detail: parsed }),
|
|
84
|
+
)
|
|
85
|
+
} catch {
|
|
86
|
+
// Ignore malformed events
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
source.onerror = () => {
|
|
91
|
+
disconnect()
|
|
92
|
+
if (mounted) scheduleReconnect()
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
if (mounted) scheduleReconnect()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function disconnect() {
|
|
100
|
+
if (sourceRef.current) {
|
|
101
|
+
sourceRef.current.close()
|
|
102
|
+
sourceRef.current = null
|
|
103
|
+
}
|
|
104
|
+
if (heartbeatTimer.current) {
|
|
105
|
+
clearTimeout(heartbeatTimer.current)
|
|
106
|
+
heartbeatTimer.current = null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scheduleReconnect() {
|
|
111
|
+
if (reconnectTimer.current) return
|
|
112
|
+
const delay = Math.min(
|
|
113
|
+
RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts.current),
|
|
114
|
+
RECONNECT_MAX_MS,
|
|
115
|
+
)
|
|
116
|
+
reconnectAttempts.current++
|
|
117
|
+
reconnectTimer.current = setTimeout(() => {
|
|
118
|
+
reconnectTimer.current = null
|
|
119
|
+
connect()
|
|
120
|
+
}, delay)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
connect()
|
|
124
|
+
|
|
125
|
+
return () => {
|
|
126
|
+
mounted = false
|
|
127
|
+
disconnect()
|
|
128
|
+
if (reconnectTimer.current) {
|
|
129
|
+
clearTimeout(reconnectTimer.current)
|
|
130
|
+
reconnectTimer.current = null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}, [])
|
|
134
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { InjectionMenuItem } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
|
+
import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
|
|
3
|
+
|
|
4
|
+
export type MergedMenuItem = {
|
|
5
|
+
id: string
|
|
6
|
+
label?: string
|
|
7
|
+
labelKey?: string
|
|
8
|
+
icon?: string
|
|
9
|
+
href?: string
|
|
10
|
+
onClick?: () => void
|
|
11
|
+
separator?: boolean
|
|
12
|
+
badge?: string | number
|
|
13
|
+
groupId?: string
|
|
14
|
+
groupLabel?: string
|
|
15
|
+
groupLabelKey?: string
|
|
16
|
+
groupOrder?: number
|
|
17
|
+
children?: Omit<InjectionMenuItem, 'children'>[]
|
|
18
|
+
source: 'built-in' | 'injected'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type BuiltInMenuItem = {
|
|
22
|
+
id: string
|
|
23
|
+
[key: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toMergedInjectedItem(item: InjectionMenuItem): MergedMenuItem {
|
|
27
|
+
return {
|
|
28
|
+
id: item.id,
|
|
29
|
+
label: item.label,
|
|
30
|
+
labelKey: item.labelKey,
|
|
31
|
+
icon: item.icon,
|
|
32
|
+
href: item.href,
|
|
33
|
+
onClick: item.onClick,
|
|
34
|
+
separator: item.separator,
|
|
35
|
+
badge: item.badge,
|
|
36
|
+
groupId: item.groupId,
|
|
37
|
+
groupLabel: item.groupLabel,
|
|
38
|
+
groupLabelKey: item.groupLabelKey,
|
|
39
|
+
groupOrder: item.groupOrder,
|
|
40
|
+
children: item.children,
|
|
41
|
+
source: 'injected',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mergeMenuItems(
|
|
46
|
+
builtIn: BuiltInMenuItem[],
|
|
47
|
+
injected: InjectionMenuItem[],
|
|
48
|
+
): MergedMenuItem[] {
|
|
49
|
+
let merged: MergedMenuItem[] = builtIn.map((item) => ({
|
|
50
|
+
...(item as Record<string, unknown>),
|
|
51
|
+
id: item.id,
|
|
52
|
+
source: 'built-in',
|
|
53
|
+
})) as MergedMenuItem[]
|
|
54
|
+
|
|
55
|
+
for (const item of injected) {
|
|
56
|
+
const nextItem = toMergedInjectedItem(item)
|
|
57
|
+
if (!item.placement && item.groupId) {
|
|
58
|
+
const existingGroupIndexes = merged
|
|
59
|
+
.map((entry, index) => ({ entry, index }))
|
|
60
|
+
.filter(({ entry }) => entry.groupId === item.groupId)
|
|
61
|
+
if (existingGroupIndexes.length > 0) {
|
|
62
|
+
const insertAfter = existingGroupIndexes[existingGroupIndexes.length - 1]?.index ?? -1
|
|
63
|
+
merged.splice(insertAfter + 1, 0, nextItem)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
merged = insertByInjectionPlacement(merged, nextItem, item.placement, (entry) => entry.id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return merged
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as LucideIcons from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
type LucideIconComponent = React.ComponentType<{ className?: string }>
|
|
5
|
+
|
|
6
|
+
function toPascalCaseIconName(name: string): string {
|
|
7
|
+
if (!name.includes('-') && !name.includes('_') && !name.includes(' ')) return name
|
|
8
|
+
return name
|
|
9
|
+
.split(/[-_\s]+/)
|
|
10
|
+
.filter((part) => part.length > 0)
|
|
11
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
12
|
+
.join('')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveInjectedIcon(icon?: string, className = 'size-4'): React.ReactNode | null {
|
|
16
|
+
if (!icon) return null
|
|
17
|
+
const normalized = icon.trim()
|
|
18
|
+
if (!normalized) return null
|
|
19
|
+
|
|
20
|
+
const candidates = [normalized, toPascalCaseIconName(normalized)]
|
|
21
|
+
const registry = LucideIcons as unknown as Record<string, LucideIconComponent | undefined>
|
|
22
|
+
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
const IconComponent = registry[candidate]
|
|
25
|
+
if (!IconComponent) continue
|
|
26
|
+
return <IconComponent className={className} />
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
@@ -5,3 +5,41 @@ export const BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID: InjectionSpotId = 'backend:la
|
|
|
5
5
|
export const BACKEND_LAYOUT_FOOTER_INJECTION_SPOT_ID: InjectionSpotId = 'backend:layout:footer'
|
|
6
6
|
export const BACKEND_SIDEBAR_TOP_INJECTION_SPOT_ID: InjectionSpotId = 'backend:sidebar:top'
|
|
7
7
|
export const BACKEND_SIDEBAR_FOOTER_INJECTION_SPOT_ID: InjectionSpotId = 'backend:sidebar:footer'
|
|
8
|
+
|
|
9
|
+
// Standardized backend chrome spot ids
|
|
10
|
+
export const BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID: InjectionSpotId = 'backend:topbar:profile-menu'
|
|
11
|
+
export const BACKEND_TOPBAR_ACTIONS_INJECTION_SPOT_ID: InjectionSpotId = 'backend:topbar:actions'
|
|
12
|
+
export const BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID: InjectionSpotId = 'backend:sidebar:nav'
|
|
13
|
+
export const BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID: InjectionSpotId = 'backend:sidebar:nav:footer'
|
|
14
|
+
|
|
15
|
+
// Standardized global status spot ids
|
|
16
|
+
export const GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID: InjectionSpotId = 'global:sidebar:status-badges'
|
|
17
|
+
export const GLOBAL_HEADER_STATUS_INDICATORS_INJECTION_SPOT_ID: InjectionSpotId = 'global:header:status-indicators'
|
|
18
|
+
|
|
19
|
+
// Standardized pattern helpers
|
|
20
|
+
export const CrudFormInjectionSpots = {
|
|
21
|
+
base: (entityId: string): InjectionSpotId => `crud-form:${entityId}`,
|
|
22
|
+
beforeFields: (entityId: string): InjectionSpotId => `crud-form:${entityId}:before-fields`,
|
|
23
|
+
afterFields: (entityId: string): InjectionSpotId => `crud-form:${entityId}:after-fields`,
|
|
24
|
+
header: (entityId: string): InjectionSpotId => `crud-form:${entityId}:header`,
|
|
25
|
+
footer: (entityId: string): InjectionSpotId => `crud-form:${entityId}:footer`,
|
|
26
|
+
sidebar: (entityId: string): InjectionSpotId => `crud-form:${entityId}:sidebar`,
|
|
27
|
+
group: (entityId: string, groupId: string): InjectionSpotId => `crud-form:${entityId}:group:${groupId}`,
|
|
28
|
+
fieldBefore: (entityId: string, fieldId: string): InjectionSpotId => `crud-form:${entityId}:field:${fieldId}:before`,
|
|
29
|
+
fieldAfter: (entityId: string, fieldId: string): InjectionSpotId => `crud-form:${entityId}:field:${fieldId}:after`,
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
export const DataTableInjectionSpots = {
|
|
33
|
+
header: (tableId: string): InjectionSpotId => `data-table:${tableId}:header`,
|
|
34
|
+
footer: (tableId: string): InjectionSpotId => `data-table:${tableId}:footer`,
|
|
35
|
+
toolbar: (tableId: string): InjectionSpotId => `data-table:${tableId}:toolbar`,
|
|
36
|
+
emptyState: (tableId: string): InjectionSpotId => `data-table:${tableId}:empty-state`,
|
|
37
|
+
} as const
|
|
38
|
+
|
|
39
|
+
export const DetailInjectionSpots = {
|
|
40
|
+
header: (entityId: string): InjectionSpotId => `detail:${entityId}:header`,
|
|
41
|
+
tabs: (entityId: string): InjectionSpotId => `detail:${entityId}:tabs`,
|
|
42
|
+
sidebar: (entityId: string): InjectionSpotId => `detail:${entityId}:sidebar`,
|
|
43
|
+
footer: (entityId: string): InjectionSpotId => `detail:${entityId}:footer`,
|
|
44
|
+
statusBadges: (entityId: string): InjectionSpotId => `detail:${entityId}:status-badges`,
|
|
45
|
+
} as const
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DOM Event Bridge event name.
|
|
7
|
+
* Server-side events with `clientBroadcast: true` are dispatched as
|
|
8
|
+
* CustomEvents with this name on the window object.
|
|
9
|
+
*/
|
|
10
|
+
export const APP_EVENT_DOM_NAME = 'om:event'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Match an event ID against a wildcard pattern.
|
|
14
|
+
*
|
|
15
|
+
* Supports:
|
|
16
|
+
* - Exact match: `'example.todo.created'`
|
|
17
|
+
* - Wildcard suffix: `'example.todo.*'` matches `'example.todo.created'`, `'example.todo.updated'`
|
|
18
|
+
* - Global wildcard: `'*'` matches everything
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* matchesPattern('example.todo.*', 'example.todo.created') // true
|
|
22
|
+
* matchesPattern('example.todo.*', 'example.item.created') // false
|
|
23
|
+
* matchesPattern('*', 'anything.here') // true
|
|
24
|
+
*/
|
|
25
|
+
export function matchesPattern(pattern: string, eventId: string): boolean {
|
|
26
|
+
if (pattern === '*') return true
|
|
27
|
+
if (!pattern.includes('*')) return pattern === eventId
|
|
28
|
+
const escapedPattern = pattern
|
|
29
|
+
.replace(/[|\\{}()[\]^$+?.]/g, '\\$&')
|
|
30
|
+
.replace(/\*/g, '.*')
|
|
31
|
+
const regex = new RegExp(
|
|
32
|
+
'^' + escapedPattern + '$',
|
|
33
|
+
)
|
|
34
|
+
return regex.test(eventId)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* React hook that listens for app events delivered via the DOM Event Bridge.
|
|
39
|
+
*
|
|
40
|
+
* Events are dispatched as `om:event` CustomEvents on `window` by the event bridge
|
|
41
|
+
* (see `eventBridge.ts`). This hook filters events by pattern and calls the handler.
|
|
42
|
+
*
|
|
43
|
+
* @param eventPattern - Pattern to match event IDs against (e.g., 'example.todo.*')
|
|
44
|
+
* @param handler - Callback invoked when a matching event arrives
|
|
45
|
+
* @param deps - Optional dependency array for the handler (defaults to [])
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* useAppEvent('example.todo.*', (event) => {
|
|
49
|
+
* console.log('Todo event:', event.id, event.payload)
|
|
50
|
+
* setRefreshKey(k => k + 1)
|
|
51
|
+
* })
|
|
52
|
+
*/
|
|
53
|
+
export function useAppEvent(
|
|
54
|
+
eventPattern: string,
|
|
55
|
+
handler: (payload: AppEventPayload) => void,
|
|
56
|
+
deps: unknown[] = [],
|
|
57
|
+
): void {
|
|
58
|
+
const handlerRef = useRef(handler)
|
|
59
|
+
handlerRef.current = handler
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const listener = (e: Event) => {
|
|
63
|
+
const detail = (e as CustomEvent<AppEventPayload>).detail
|
|
64
|
+
if (!detail || typeof detail.id !== 'string') return
|
|
65
|
+
if (matchesPattern(eventPattern, detail.id)) {
|
|
66
|
+
handlerRef.current(detail)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
window.addEventListener(APP_EVENT_DOM_NAME, listener)
|
|
71
|
+
return () => {
|
|
72
|
+
window.removeEventListener(APP_EVENT_DOM_NAME, listener)
|
|
73
|
+
}
|
|
74
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
75
|
+
}, [eventPattern, ...deps])
|
|
76
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { InjectionMenuItem } from '@open-mercato/shared/modules/widgets/injection'
|
|
5
|
+
import { useInjectionDataWidgets } from './useInjectionDataWidgets'
|
|
6
|
+
import { apiCall } from '../utils/apiCall'
|
|
7
|
+
|
|
8
|
+
export type MenuSurfaceId =
|
|
9
|
+
| 'menu:sidebar:main'
|
|
10
|
+
| 'menu:sidebar:settings'
|
|
11
|
+
| 'menu:sidebar:profile'
|
|
12
|
+
| 'menu:topbar:profile-dropdown'
|
|
13
|
+
| 'menu:topbar:actions'
|
|
14
|
+
| `menu:sidebar:settings:${string}`
|
|
15
|
+
| `menu:sidebar:main:${string}`
|
|
16
|
+
| `menu:${string}`
|
|
17
|
+
|
|
18
|
+
type FeatureCheckResponse = {
|
|
19
|
+
ok: boolean
|
|
20
|
+
granted?: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ProfileResponse = {
|
|
24
|
+
email?: string
|
|
25
|
+
roles?: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function collectRequiredFeatures(items: InjectionMenuItem[]): string[] {
|
|
29
|
+
const set = new Set<string>()
|
|
30
|
+
for (const item of items) {
|
|
31
|
+
for (const feature of item.features ?? []) {
|
|
32
|
+
if (!feature || feature.trim().length === 0) continue
|
|
33
|
+
set.add(feature)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return Array.from(set)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readGrantedFeatures(features: string[]): Promise<Set<string>> {
|
|
40
|
+
if (features.length === 0) return new Set()
|
|
41
|
+
const call = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'content-type': 'application/json' },
|
|
44
|
+
body: JSON.stringify({ features }),
|
|
45
|
+
})
|
|
46
|
+
if (!call.ok) return new Set()
|
|
47
|
+
return new Set(call.result?.granted ?? [])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readUserRoles(): Promise<Set<string>> {
|
|
51
|
+
const call = await apiCall<ProfileResponse>('/api/auth/profile')
|
|
52
|
+
if (!call.ok) return new Set()
|
|
53
|
+
const roles = Array.isArray(call.result?.roles) ? call.result.roles : []
|
|
54
|
+
return new Set(roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useInjectedMenuItems(surfaceId: MenuSurfaceId): {
|
|
58
|
+
items: InjectionMenuItem[]
|
|
59
|
+
isLoading: boolean
|
|
60
|
+
} {
|
|
61
|
+
const { widgets, isLoading } = useInjectionDataWidgets(surfaceId)
|
|
62
|
+
const [grantedFeatures, setGrantedFeatures] = React.useState<Set<string>>(new Set())
|
|
63
|
+
const [userRoles, setUserRoles] = React.useState<Set<string>>(new Set())
|
|
64
|
+
|
|
65
|
+
const rawItems = React.useMemo(() => {
|
|
66
|
+
const entries: InjectionMenuItem[] = []
|
|
67
|
+
for (const widget of widgets) {
|
|
68
|
+
if (!('menuItems' in widget)) continue
|
|
69
|
+
const metadataFeatures = widget.metadata.features ?? []
|
|
70
|
+
for (const menuItem of widget.menuItems) {
|
|
71
|
+
const features = [...metadataFeatures, ...(menuItem.features ?? [])]
|
|
72
|
+
const normalizedLabelKey =
|
|
73
|
+
menuItem.labelKey ??
|
|
74
|
+
(typeof menuItem.label === 'string' && menuItem.label.includes('.') ? menuItem.label : undefined)
|
|
75
|
+
entries.push({
|
|
76
|
+
...menuItem,
|
|
77
|
+
labelKey: normalizedLabelKey,
|
|
78
|
+
features,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return entries
|
|
83
|
+
}, [widgets])
|
|
84
|
+
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
let mounted = true
|
|
87
|
+
const run = async () => {
|
|
88
|
+
const features = collectRequiredFeatures(rawItems)
|
|
89
|
+
const next = await readGrantedFeatures(features)
|
|
90
|
+
if (!mounted) return
|
|
91
|
+
setGrantedFeatures(next)
|
|
92
|
+
}
|
|
93
|
+
void run()
|
|
94
|
+
return () => {
|
|
95
|
+
mounted = false
|
|
96
|
+
}
|
|
97
|
+
}, [rawItems])
|
|
98
|
+
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
let mounted = true
|
|
101
|
+
const run = async () => {
|
|
102
|
+
const roles = await readUserRoles()
|
|
103
|
+
if (!mounted) return
|
|
104
|
+
setUserRoles(roles)
|
|
105
|
+
}
|
|
106
|
+
void run()
|
|
107
|
+
return () => {
|
|
108
|
+
mounted = false
|
|
109
|
+
}
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
const items = React.useMemo(
|
|
113
|
+
() =>
|
|
114
|
+
rawItems.filter((item) => {
|
|
115
|
+
const features = item.features ?? []
|
|
116
|
+
const roles = item.roles ?? []
|
|
117
|
+
const featuresOk = features.length === 0 || features.every((feature) => grantedFeatures.has(feature))
|
|
118
|
+
const rolesOk = roles.length === 0 || roles.some((role) => userRoles.has(role))
|
|
119
|
+
return featuresOk && rolesOk
|
|
120
|
+
}),
|
|
121
|
+
[rawItems, grantedFeatures, userRoles],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return { items, isLoading }
|
|
125
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'
|
|
5
|
+
import { loadInjectionDataWidgetsForSpot, type LoadedInjectionDataWidget } from '@open-mercato/shared/modules/widgets/injection-loader'
|
|
6
|
+
|
|
7
|
+
export function useInjectionDataWidgets(spotId: InjectionSpotId): {
|
|
8
|
+
widgets: LoadedInjectionDataWidget[]
|
|
9
|
+
isLoading: boolean
|
|
10
|
+
error: string | null
|
|
11
|
+
} {
|
|
12
|
+
const [widgets, setWidgets] = React.useState<LoadedInjectionDataWidget[]>([])
|
|
13
|
+
const [isLoading, setIsLoading] = React.useState(true)
|
|
14
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
15
|
+
|
|
16
|
+
React.useEffect(() => {
|
|
17
|
+
let mounted = true
|
|
18
|
+
const load = async () => {
|
|
19
|
+
try {
|
|
20
|
+
setIsLoading(true)
|
|
21
|
+
setError(null)
|
|
22
|
+
const loaded = await loadInjectionDataWidgetsForSpot(spotId)
|
|
23
|
+
if (!mounted) return
|
|
24
|
+
setWidgets(loaded)
|
|
25
|
+
} catch (loadError) {
|
|
26
|
+
if (!mounted) return
|
|
27
|
+
console.error(`[useInjectionDataWidgets] Failed to load widgets for spot ${spotId}:`, loadError)
|
|
28
|
+
setError(loadError instanceof Error ? loadError.message : String(loadError))
|
|
29
|
+
setWidgets([])
|
|
30
|
+
} finally {
|
|
31
|
+
if (mounted) setIsLoading(false)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
void load()
|
|
35
|
+
return () => {
|
|
36
|
+
mounted = false
|
|
37
|
+
}
|
|
38
|
+
}, [spotId])
|
|
39
|
+
|
|
40
|
+
return { widgets, isLoading, error }
|
|
41
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import { useState, useRef, useEffect } from 'react'
|
|
3
|
+
import type { OperationProgressEvent } from '@open-mercato/shared/modules/widgets/injection-progress'
|
|
4
|
+
import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
|
|
5
|
+
import { useAppEvent } from './useAppEvent'
|
|
6
|
+
|
|
7
|
+
type ProgressStatus = 'idle' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
8
|
+
|
|
9
|
+
interface OperationProgressState {
|
|
10
|
+
status: ProgressStatus
|
|
11
|
+
progress: number
|
|
12
|
+
processedCount: number
|
|
13
|
+
totalCount: number
|
|
14
|
+
currentStep?: string
|
|
15
|
+
errors: number
|
|
16
|
+
startedAt?: number
|
|
17
|
+
elapsedMs: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const IDLE_STATE: OperationProgressState = {
|
|
21
|
+
status: 'idle',
|
|
22
|
+
progress: 0,
|
|
23
|
+
processedCount: 0,
|
|
24
|
+
totalCount: 0,
|
|
25
|
+
errors: 0,
|
|
26
|
+
elapsedMs: 0,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* React hook that tracks long-running operation progress via the DOM Event Bridge.
|
|
31
|
+
*
|
|
32
|
+
* Listens for progress events matching the given pattern and aggregates state.
|
|
33
|
+
* When `operationId` is provided, only events for that specific operation are tracked.
|
|
34
|
+
*
|
|
35
|
+
* @param operationPattern - Event pattern to listen for (e.g., 'integration.sync.progress')
|
|
36
|
+
* @param operationId - Optional filter for a specific operation instance
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const progress = useOperationProgress('integration.sync.progress', syncRunId)
|
|
40
|
+
* if (progress.status === 'running') {
|
|
41
|
+
* return <ProgressBar value={progress.progress} />
|
|
42
|
+
* }
|
|
43
|
+
*/
|
|
44
|
+
export function useOperationProgress(
|
|
45
|
+
operationPattern: string,
|
|
46
|
+
operationId?: string,
|
|
47
|
+
): OperationProgressState {
|
|
48
|
+
const [state, setState] = useState<OperationProgressState>(IDLE_STATE)
|
|
49
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
50
|
+
const startedAtRef = useRef<number | undefined>(undefined)
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
return () => {
|
|
54
|
+
if (timerRef.current) {
|
|
55
|
+
clearInterval(timerRef.current)
|
|
56
|
+
timerRef.current = null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
useAppEvent(
|
|
62
|
+
operationPattern,
|
|
63
|
+
(event: AppEventPayload) => {
|
|
64
|
+
const payload = event.payload as unknown as OperationProgressEvent
|
|
65
|
+
if (!payload || typeof payload.status !== 'string') return
|
|
66
|
+
if (operationId && payload.operationId !== operationId) return
|
|
67
|
+
|
|
68
|
+
startedAtRef.current = payload.startedAt
|
|
69
|
+
|
|
70
|
+
const newState: OperationProgressState = {
|
|
71
|
+
status: payload.status,
|
|
72
|
+
progress: payload.progress,
|
|
73
|
+
processedCount: payload.processedCount,
|
|
74
|
+
totalCount: payload.totalCount,
|
|
75
|
+
currentStep: payload.currentStep,
|
|
76
|
+
errors: payload.errors,
|
|
77
|
+
startedAt: payload.startedAt,
|
|
78
|
+
elapsedMs: payload.startedAt ? Date.now() - payload.startedAt : 0,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setState(newState)
|
|
82
|
+
|
|
83
|
+
// Start elapsed time ticker when running
|
|
84
|
+
if (payload.status === 'running' && !timerRef.current) {
|
|
85
|
+
timerRef.current = setInterval(() => {
|
|
86
|
+
if (startedAtRef.current) {
|
|
87
|
+
setState((prev) => ({
|
|
88
|
+
...prev,
|
|
89
|
+
elapsedMs: Date.now() - startedAtRef.current!,
|
|
90
|
+
}))
|
|
91
|
+
}
|
|
92
|
+
}, 1000)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Clear ticker when operation ends
|
|
96
|
+
if (payload.status !== 'running' && timerRef.current) {
|
|
97
|
+
clearInterval(timerRef.current)
|
|
98
|
+
timerRef.current = null
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
[operationId],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return state
|
|
105
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { getWidgetSharedState } from './WidgetSharedState'
|
|
5
|
+
|
|
6
|
+
export function useWidgetSharedState<T>(key: string, namespace = 'global'): [T | undefined, (value: T) => void] {
|
|
7
|
+
const store = React.useMemo(() => getWidgetSharedState(namespace), [namespace])
|
|
8
|
+
|
|
9
|
+
const subscribe = React.useCallback(
|
|
10
|
+
(onStoreChange: () => void) => {
|
|
11
|
+
const unsubscribe = store.subscribe(key, () => onStoreChange())
|
|
12
|
+
return unsubscribe
|
|
13
|
+
},
|
|
14
|
+
[key, store],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const getSnapshot = React.useCallback(() => store.get<T>(key), [key, store])
|
|
18
|
+
const value = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
19
|
+
|
|
20
|
+
const setValue = React.useCallback(
|
|
21
|
+
(nextValue: T) => {
|
|
22
|
+
store.set<T>(key, nextValue)
|
|
23
|
+
},
|
|
24
|
+
[key, store],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return [value, setValue]
|
|
28
|
+
}
|