@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.
Files changed (54) hide show
  1. package/AGENTS.md +8 -0
  2. package/dist/backend/AppShell.js +395 -134
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/CrudForm.js +232 -21
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/ProfileDropdown.js +214 -94
  7. package/dist/backend/ProfileDropdown.js.map +2 -2
  8. package/dist/backend/injection/InjectionSpot.js +74 -4
  9. package/dist/backend/injection/InjectionSpot.js.map +2 -2
  10. package/dist/backend/injection/SseEventIndicator.js +16 -0
  11. package/dist/backend/injection/SseEventIndicator.js.map +7 -0
  12. package/dist/backend/injection/WidgetSharedState.js +49 -0
  13. package/dist/backend/injection/WidgetSharedState.js.map +7 -0
  14. package/dist/backend/injection/eventBridge.js +105 -0
  15. package/dist/backend/injection/eventBridge.js.map +7 -0
  16. package/dist/backend/injection/mergeMenuItems.js +43 -0
  17. package/dist/backend/injection/mergeMenuItems.js.map +7 -0
  18. package/dist/backend/injection/resolveInjectedIcon.js +23 -0
  19. package/dist/backend/injection/resolveInjectedIcon.js.map +7 -0
  20. package/dist/backend/injection/spotIds.js +40 -1
  21. package/dist/backend/injection/spotIds.js.map +2 -2
  22. package/dist/backend/injection/useAppEvent.js +35 -0
  23. package/dist/backend/injection/useAppEvent.js.map +7 -0
  24. package/dist/backend/injection/useInjectedMenuItems.js +92 -0
  25. package/dist/backend/injection/useInjectedMenuItems.js.map +7 -0
  26. package/dist/backend/injection/useInjectionDataWidgets.js +36 -0
  27. package/dist/backend/injection/useInjectionDataWidgets.js.map +7 -0
  28. package/dist/backend/injection/useOperationProgress.js +64 -0
  29. package/dist/backend/injection/useOperationProgress.js.map +7 -0
  30. package/dist/backend/injection/useWidgetSharedState.js +26 -0
  31. package/dist/backend/injection/useWidgetSharedState.js.map +7 -0
  32. package/dist/backend/section-page/SectionNav.js +22 -2
  33. package/dist/backend/section-page/SectionNav.js.map +2 -2
  34. package/dist/backend/utils/api.js +9 -1
  35. package/dist/backend/utils/api.js.map +2 -2
  36. package/package.json +2 -2
  37. package/src/backend/AGENTS.md +50 -0
  38. package/src/backend/AppShell.tsx +317 -30
  39. package/src/backend/CrudForm.tsx +238 -21
  40. package/src/backend/ProfileDropdown.tsx +199 -78
  41. package/src/backend/injection/InjectionSpot.tsx +118 -16
  42. package/src/backend/injection/SseEventIndicator.tsx +24 -0
  43. package/src/backend/injection/WidgetSharedState.ts +58 -0
  44. package/src/backend/injection/eventBridge.ts +134 -0
  45. package/src/backend/injection/mergeMenuItems.ts +71 -0
  46. package/src/backend/injection/resolveInjectedIcon.tsx +30 -0
  47. package/src/backend/injection/spotIds.ts +38 -0
  48. package/src/backend/injection/useAppEvent.ts +76 -0
  49. package/src/backend/injection/useInjectedMenuItems.ts +125 -0
  50. package/src/backend/injection/useInjectionDataWidgets.ts +41 -0
  51. package/src/backend/injection/useOperationProgress.ts +105 -0
  52. package/src/backend/injection/useWidgetSharedState.ts +28 -0
  53. package/src/backend/section-page/SectionNav.tsx +22 -1
  54. 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
+ }