@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
@@ -7,6 +7,11 @@ import { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
7
7
  import { useTheme } from '@open-mercato/ui/theme'
8
8
  import { Button } from '../primitives/button'
9
9
  import { IconButton } from '../primitives/icon-button'
10
+ import { useInjectedMenuItems } from './injection/useInjectedMenuItems'
11
+ import { mergeMenuItems, type MergedMenuItem } from './injection/mergeMenuItems'
12
+ import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
13
+ import { InjectionSpot } from './injection/InjectionSpot'
14
+ import { BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID } from './injection/spotIds'
10
15
 
11
16
  export type ProfileDropdownProps = {
12
17
  email?: string
@@ -36,6 +41,7 @@ export function ProfileDropdown({
36
41
  const [mounted, setMounted] = React.useState(false)
37
42
  const buttonRef = React.useRef<HTMLButtonElement>(null)
38
43
  const menuRef = React.useRef<HTMLDivElement>(null)
44
+ const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')
39
45
 
40
46
  React.useEffect(() => {
41
47
  setMounted(true)
@@ -96,47 +102,86 @@ export function ProfileDropdown({
96
102
  const menuItemClass =
97
103
  'w-full text-left text-sm cursor-pointer px-3 py-2 rounded hover:bg-accent inline-flex items-center gap-2.5 outline-none focus-visible:ring-1 focus-visible:ring-ring'
98
104
 
99
- return (
100
- <div className="relative">
101
- <IconButton
102
- ref={buttonRef}
103
- variant="ghost"
104
- size="sm"
105
- onClick={() => setOpen(!open)}
106
- aria-expanded={open}
107
- aria-haspopup="menu"
108
- title={email || t('ui.userMenu.userFallback', 'User')}
109
- >
110
- <User className="size-4" />
111
- </IconButton>
105
+ const resolveMenuLabel = React.useCallback(
106
+ (item: Pick<MergedMenuItem, 'id' | 'label' | 'labelKey'>): string => {
107
+ if (item.labelKey && item.label) return t(item.labelKey, item.label)
108
+ if (item.labelKey) return t(item.labelKey, item.id)
109
+ if (item.label && item.label.includes('.')) return t(item.label, item.id)
110
+ return item.label ?? item.id
111
+ },
112
+ [t],
113
+ )
112
114
 
113
- {open && (
114
- <div
115
- ref={menuRef}
116
- className="absolute right-0 top-full mt-1 w-56 rounded-md border bg-background p-1 shadow-lg z-50"
117
- role="menu"
115
+ const builtInMenuItems = React.useMemo(
116
+ () => {
117
+ const items: Array<{ id: string; separator?: boolean }> = [{ id: 'change-password' }]
118
+ if (notificationsHref) items.push({ id: 'notifications' })
119
+ items.push({ id: 'theme-toggle', separator: true }, { id: 'language' }, { id: 'sign-out', separator: true })
120
+ return items
121
+ },
122
+ [notificationsHref],
123
+ )
124
+
125
+ const mergedMenuItems = React.useMemo(
126
+ () => mergeMenuItems(builtInMenuItems, injectedItems),
127
+ [builtInMenuItems, injectedItems],
128
+ )
129
+ const injectionContext = React.useMemo(
130
+ () => ({
131
+ email,
132
+ displayName,
133
+ locale: currentLocale,
134
+ }),
135
+ [currentLocale, displayName, email],
136
+ )
137
+
138
+ const renderInjectedItem = React.useCallback(
139
+ (item: MergedMenuItem) => {
140
+ const label = resolveMenuLabel(item)
141
+ const icon = resolveInjectedIcon(item.icon)
142
+ if (item.href) {
143
+ return (
144
+ <Link
145
+ key={item.id}
146
+ href={item.href}
147
+ className={menuItemClass}
148
+ role="menuitem"
149
+ data-menu-item-id={item.id}
150
+ onClick={() => setOpen(false)}
151
+ >
152
+ {icon}
153
+ <span>{label}</span>
154
+ </Link>
155
+ )
156
+ }
157
+ return (
158
+ <Button
159
+ key={item.id}
160
+ type="button"
161
+ variant="ghost"
162
+ size="sm"
163
+ className="w-full justify-start"
164
+ role="menuitem"
165
+ data-menu-item-id={item.id}
166
+ onClick={() => {
167
+ item.onClick?.()
168
+ setOpen(false)
169
+ }}
118
170
  >
119
- {/* User info header */}
120
- {(displayName || email) && (
121
- <div className="px-3 py-2.5 border-b mb-1">
122
- {displayName && (
123
- <div className="font-medium text-sm flex items-center gap-2">
124
- <User className="size-4" />
125
- {displayName}
126
- </div>
127
- )}
128
- {displayName && email && (
129
- <div className="text-xs text-muted-foreground mt-0.5 ml-6">{email}</div>
130
- )}
131
- {!displayName && email && (
132
- <div className="text-xs text-muted-foreground">
133
- {t('ui.userMenu.loggedInAs', 'Logged in as:')} {email}
134
- </div>
135
- )}
136
- </div>
137
- )}
171
+ {icon}
172
+ <span>{label}</span>
173
+ </Button>
174
+ )
175
+ },
176
+ [menuItemClass, resolveMenuLabel],
177
+ )
138
178
 
179
+ const renderBuiltInItem = React.useCallback(
180
+ (id: string) => {
181
+ if (id === 'change-password') {
182
+ return (
139
183
  <Link
184
+ key={id}
140
185
  href={changePasswordHref}
141
186
  className={menuItemClass}
142
187
  role="menuitem"
@@ -145,44 +190,49 @@ export function ProfileDropdown({
145
190
  <Key className="size-4" />
146
191
  <span>{t('ui.profileMenu.changePassword', 'Change Password')}</span>
147
192
  </Link>
193
+ )
194
+ }
148
195
 
149
- {/* Notification Preferences */}
150
- {notificationsHref && (
151
- <Link
152
- href={notificationsHref}
153
- className={menuItemClass}
154
- role="menuitem"
155
- onClick={() => setOpen(false)}
156
- >
157
- <Bell className="size-4" />
158
- <span>{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>
159
- </Link>
160
- )}
161
-
162
- <div className="my-1 border-t" />
196
+ if (id === 'notifications' && notificationsHref) {
197
+ return (
198
+ <Link
199
+ key={id}
200
+ href={notificationsHref}
201
+ className={menuItemClass}
202
+ role="menuitem"
203
+ onClick={() => setOpen(false)}
204
+ >
205
+ <Bell className="size-4" />
206
+ <span>{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>
207
+ </Link>
208
+ )
209
+ }
163
210
 
164
- {/* Theme Toggle */}
165
- {mounted && (
166
- <Button
167
- type="button"
168
- variant="ghost"
169
- size="sm"
170
- className="w-full justify-between"
171
- role="menuitem"
172
- onClick={handleThemeToggle}
173
- >
174
- <span className="inline-flex items-center gap-2.5">
175
- {isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
176
- <span>{t('ui.profileMenu.theme', 'Dark Mode')}</span>
177
- </span>
178
- <div className={`w-8 h-4 rounded-full transition-colors ${isDark ? 'bg-primary' : 'bg-muted'} relative`}>
179
- <div className={`absolute top-0.5 w-3 h-3 rounded-full bg-background shadow transition-transform ${isDark ? 'translate-x-4' : 'translate-x-0.5'}`} />
180
- </div>
181
- </Button>
182
- )}
211
+ if (id === 'theme-toggle') {
212
+ return mounted ? (
213
+ <Button
214
+ key={id}
215
+ type="button"
216
+ variant="ghost"
217
+ size="sm"
218
+ className="w-full justify-between"
219
+ role="menuitem"
220
+ onClick={handleThemeToggle}
221
+ >
222
+ <span className="inline-flex items-center gap-2.5">
223
+ {isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
224
+ <span>{t('ui.profileMenu.theme', 'Dark Mode')}</span>
225
+ </span>
226
+ <div className={`w-8 h-4 rounded-full transition-colors ${isDark ? 'bg-primary' : 'bg-muted'} relative`}>
227
+ <div className={`absolute top-0.5 w-3 h-3 rounded-full bg-background shadow transition-transform ${isDark ? 'translate-x-4' : 'translate-x-0.5'}`} />
228
+ </div>
229
+ </Button>
230
+ ) : null
231
+ }
183
232
 
184
- {/* Language Selector */}
185
- <div className="relative">
233
+ if (id === 'language') {
234
+ return (
235
+ <div key={id} className="relative">
186
236
  <Button
187
237
  type="button"
188
238
  variant="ghost"
@@ -200,8 +250,6 @@ export function ProfileDropdown({
200
250
  {localeLabels[currentLocale]}
201
251
  </span>
202
252
  </Button>
203
-
204
- {/* Language submenu - inline below */}
205
253
  {languageOpen && (
206
254
  <div className="mt-1 ml-6 space-y-0.5 border-l pl-2">
207
255
  {locales.map((locale) => (
@@ -220,11 +268,12 @@ export function ProfileDropdown({
220
268
  </div>
221
269
  )}
222
270
  </div>
271
+ )
272
+ }
223
273
 
224
- <div className="my-1 border-t" />
225
-
226
- {/* Sign Out */}
227
- <form action="/api/auth/logout" method="POST">
274
+ if (id === 'sign-out') {
275
+ return (
276
+ <form key={id} action="/api/auth/logout" method="POST">
228
277
  <Button
229
278
  variant="ghost"
230
279
  size="sm"
@@ -236,6 +285,78 @@ export function ProfileDropdown({
236
285
  <span>{t('ui.userMenu.logout', 'Sign Out')}</span>
237
286
  </Button>
238
287
  </form>
288
+ )
289
+ }
290
+
291
+ return null
292
+ },
293
+ [
294
+ changePasswordHref,
295
+ currentLocale,
296
+ handleThemeToggle,
297
+ isDark,
298
+ languageOpen,
299
+ menuItemClass,
300
+ mounted,
301
+ notificationsHref,
302
+ t,
303
+ ],
304
+ )
305
+
306
+ return (
307
+ <div className="relative">
308
+ <IconButton
309
+ ref={buttonRef}
310
+ variant="ghost"
311
+ size="sm"
312
+ onClick={() => setOpen(!open)}
313
+ aria-expanded={open}
314
+ aria-haspopup="menu"
315
+ data-testid="profile-dropdown-trigger"
316
+ title={email || t('ui.userMenu.userFallback', 'User')}
317
+ >
318
+ <User className="size-4" />
319
+ </IconButton>
320
+
321
+ {open && (
322
+ <div
323
+ ref={menuRef}
324
+ className="absolute right-0 top-full mt-1 w-56 rounded-md border bg-background p-1 shadow-lg z-50"
325
+ role="menu"
326
+ data-testid="profile-dropdown"
327
+ >
328
+ {/* User info header */}
329
+ {(displayName || email) && (
330
+ <div className="px-3 py-2.5 border-b mb-1">
331
+ {displayName && (
332
+ <div className="font-medium text-sm flex items-center gap-2">
333
+ <User className="size-4" />
334
+ {displayName}
335
+ </div>
336
+ )}
337
+ {displayName && email && (
338
+ <div className="text-xs text-muted-foreground mt-0.5 ml-6">{email}</div>
339
+ )}
340
+ {!displayName && email && (
341
+ <div className="text-xs text-muted-foreground">
342
+ {t('ui.userMenu.loggedInAs', 'Logged in as:')} {email}
343
+ </div>
344
+ )}
345
+ </div>
346
+ )}
347
+
348
+ {mergedMenuItems.map((item) => (
349
+ <React.Fragment key={item.id}>
350
+ {item.separator ? <div className="my-1 border-t" /> : null}
351
+ {item.source === 'injected'
352
+ ? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)
353
+ : renderBuiltInItem(item.id)}
354
+ </React.Fragment>
355
+ ))}
356
+ <InjectionSpot
357
+ spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}
358
+ context={injectionContext}
359
+ />
239
360
  </div>
240
361
  )}
241
362
  </div>
@@ -6,8 +6,11 @@ import type {
6
6
  WidgetInjectionEventHandlers,
7
7
  WidgetBeforeDeleteResult,
8
8
  WidgetBeforeSaveResult,
9
+ FieldChangeResult,
10
+ NavigateGuardResult,
9
11
  } from '@open-mercato/shared/modules/widgets/injection'
10
12
  import { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'
13
+ import { getWidgetSharedState } from './WidgetSharedState'
11
14
 
12
15
  export type InjectionSpotProps<TContext = unknown, TData = unknown> = {
13
16
  spotId: InjectionSpotId
@@ -16,20 +19,21 @@ export type InjectionSpotProps<TContext = unknown, TData = unknown> = {
16
19
  onDataChange?: (data: TData) => void
17
20
  disabled?: boolean
18
21
  onEvent?: (
19
- event:
20
- | 'onLoad'
21
- | 'onBeforeSave'
22
- | 'onSave'
23
- | 'onAfterSave'
24
- | 'onBeforeDelete'
25
- | 'onDelete'
26
- | 'onAfterDelete'
27
- | 'onDeleteError',
22
+ event: keyof WidgetInjectionEventHandlers<TContext, TData>,
28
23
  widgetId: string,
29
24
  ) => void
30
25
  widgetsOverride?: LoadedWidget[]
31
26
  }
32
27
 
28
+ /**
29
+ * Transformer events use pipeline dispatch: output of widget N becomes input of widget N+1.
30
+ */
31
+ const TRANSFORMER_EVENTS = new Set<string>([
32
+ 'transformFormData',
33
+ 'transformDisplayData',
34
+ 'transformValidation',
35
+ ])
36
+
33
37
  type LoadedWidget = {
34
38
  widgetId: string
35
39
  module: InjectionWidgetModule<any, any>
@@ -38,6 +42,20 @@ type LoadedWidget = {
38
42
  placement?: LoadedInjectionWidget['placement']
39
43
  }
40
44
 
45
+ function injectSharedStateIntoContext<TContext>(context: TContext, moduleId: string): TContext {
46
+ const sharedState = getWidgetSharedState(moduleId)
47
+ if (typeof context === 'object' && context !== null && !Array.isArray(context)) {
48
+ return {
49
+ ...(context as Record<string, unknown>),
50
+ sharedState,
51
+ } as TContext
52
+ }
53
+ return {
54
+ value: context,
55
+ sharedState,
56
+ } as TContext
57
+ }
58
+
41
59
  export function useInjectionWidgets<TContext = unknown>(
42
60
  spotId: InjectionSpotId | null | undefined,
43
61
  options?: {
@@ -80,7 +98,8 @@ export function useInjectionWidgets<TContext = unknown>(
80
98
  for (const widget of widgetList) {
81
99
  if (widget.module.eventHandlers?.onLoad) {
82
100
  try {
83
- await widget.module.eventHandlers.onLoad(options.context as TContext)
101
+ const widgetContext = injectSharedStateIntoContext(options.context as TContext, widget.moduleId)
102
+ await widget.module.eventHandlers.onLoad(widgetContext)
84
103
  options.onEvent?.('onLoad', widget.widgetId)
85
104
  } catch (err) {
86
105
  console.error(`[InjectionSpot] Error in onLoad for widget ${widget.widgetId}:`, err)
@@ -144,7 +163,7 @@ export function InjectionSpot<TContext = unknown, TData = unknown>({
144
163
  return (
145
164
  <Widget
146
165
  key={widget.widgetId}
147
- context={context}
166
+ context={injectSharedStateIntoContext(context, widget.moduleId)}
148
167
  data={data}
149
168
  onDataChange={onDataChange}
150
169
  disabled={disabled}
@@ -195,8 +214,28 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
195
214
  event: keyof WidgetInjectionEventHandlers<TContext, TData>,
196
215
  data: TData,
197
216
  context: TContext,
198
- meta?: { error?: unknown }
199
- ): Promise<{ ok: boolean; message?: string; fieldErrors?: Record<string, string>; requestHeaders?: Record<string, string>; details?: unknown }> => {
217
+ meta?: {
218
+ error?: unknown
219
+ fieldId?: string
220
+ fieldValue?: unknown
221
+ originalData?: TData
222
+ target?: unknown
223
+ visible?: boolean
224
+ appEvent?: unknown
225
+ }
226
+ ): Promise<{
227
+ ok: boolean
228
+ message?: string
229
+ fieldErrors?: Record<string, string>
230
+ requestHeaders?: Record<string, string>
231
+ details?: unknown
232
+ data?: TData
233
+ fieldChange?: {
234
+ value?: unknown
235
+ sideEffects?: Record<string, unknown>
236
+ messages?: Array<{ text: string; severity: 'info' | 'warning' | 'error' }>
237
+ }
238
+ }> => {
200
239
  const normalizeBeforeSave = (
201
240
  result: WidgetBeforeSaveResult,
202
241
  ): { ok: boolean; message?: string; fieldErrors?: Record<string, string>; requestHeaders?: Record<string, string>; details?: unknown } => {
@@ -247,21 +286,56 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
247
286
  return { ok: true }
248
287
  }
249
288
 
289
+ // --- Transformer events: pipeline dispatch ---
290
+ // Output of widget N becomes input of widget N+1
291
+ if (TRANSFORMER_EVENTS.has(event)) {
292
+ let pipelineData = data
293
+ for (const widget of widgets) {
294
+ const handler = widget.module.eventHandlers?.[event]
295
+ if (!handler) continue
296
+ try {
297
+ const widgetContext = injectSharedStateIntoContext(context, widget.moduleId)
298
+ if (event === 'transformValidation') {
299
+ pipelineData = await (handler as any)(pipelineData, meta?.originalData ?? data, widgetContext)
300
+ } else {
301
+ pipelineData = await (handler as any)(pipelineData, widgetContext)
302
+ }
303
+ } catch (err) {
304
+ console.error(`[useInjectionSpotEvents] Error in ${event} for widget ${widget.widgetId}:`, err)
305
+ }
306
+ }
307
+ return { ok: true, data: pipelineData }
308
+ }
309
+
310
+ // --- Action events: sequential dispatch ---
250
311
  const mergedRequestHeaders: Record<string, string> = {}
251
312
  let hasRequestHeaders = false
313
+ let fieldValue = meta?.fieldValue
314
+ let fieldSideEffects: Record<string, unknown> | undefined
315
+ let fieldMessages: Array<{ text: string; severity: 'info' | 'warning' | 'error' }> | undefined
252
316
 
253
317
  for (const widget of widgets) {
254
318
  const eventHandlers = widget.module.eventHandlers
255
319
  let handler = eventHandlers?.[event]
320
+ // Delete-to-save fallback chain
256
321
  if (!handler && event === 'onBeforeDelete') handler = eventHandlers?.onBeforeSave as typeof handler
257
322
  if (!handler && event === 'onDelete') handler = eventHandlers?.onSave as typeof handler
258
323
  if (!handler && event === 'onAfterDelete') handler = eventHandlers?.onAfterSave as typeof handler
259
324
  if (handler) {
260
325
  try {
326
+ const widgetContext = injectSharedStateIntoContext(context, widget.moduleId)
261
327
  const result =
262
328
  event === 'onDeleteError'
263
- ? await (handler as any)(data, context, meta?.error)
264
- : await (handler as any)(data, context)
329
+ ? await (handler as any)(data, widgetContext, meta?.error)
330
+ : event === 'onFieldChange'
331
+ ? await (handler as any)(meta?.fieldId, fieldValue, data, widgetContext)
332
+ : event === 'onBeforeNavigate'
333
+ ? await (handler as any)(meta?.target, widgetContext)
334
+ : event === 'onVisibilityChange'
335
+ ? await (handler as any)(meta?.visible, widgetContext)
336
+ : event === 'onAppEvent'
337
+ ? await (handler as any)(meta?.appEvent, widgetContext)
338
+ : await (handler as any)(data, widgetContext)
265
339
  if (event === 'onBeforeSave') {
266
340
  const normalized = normalizeBeforeSave(result as WidgetBeforeSaveResult)
267
341
  if (!normalized.ok) {
@@ -284,9 +358,27 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
284
358
  hasRequestHeaders = true
285
359
  }
286
360
  }
361
+ if (event === 'onBeforeNavigate') {
362
+ const navResult = result as NavigateGuardResult | undefined
363
+ if (navResult && navResult.ok === false) {
364
+ return { ok: false, message: navResult.message }
365
+ }
366
+ }
367
+ if (event === 'onFieldChange') {
368
+ const changeResult = result as FieldChangeResult | void
369
+ if (changeResult?.value !== undefined) {
370
+ fieldValue = changeResult.value
371
+ }
372
+ if (changeResult?.sideEffects && typeof changeResult.sideEffects === 'object') {
373
+ fieldSideEffects = { ...(fieldSideEffects ?? {}), ...changeResult.sideEffects }
374
+ }
375
+ if (changeResult?.message?.text) {
376
+ fieldMessages = [...(fieldMessages ?? []), changeResult.message]
377
+ }
378
+ }
287
379
  } catch (err) {
288
380
  console.error(`[useInjectionSpotEvents] Error in ${event} for widget ${widget.widgetId}:`, err)
289
- if (event === 'onBeforeSave' || event === 'onBeforeDelete') {
381
+ if (event === 'onBeforeSave' || event === 'onBeforeDelete' || event === 'onBeforeNavigate') {
290
382
  const message =
291
383
  err instanceof Error
292
384
  ? err.message || 'Validation blocked'
@@ -301,6 +393,16 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
301
393
  if ((event === 'onBeforeSave' || event === 'onBeforeDelete') && hasRequestHeaders) {
302
394
  return { ok: true, requestHeaders: mergedRequestHeaders }
303
395
  }
396
+ if (event === 'onFieldChange') {
397
+ return {
398
+ ok: true,
399
+ fieldChange: {
400
+ value: fieldValue,
401
+ sideEffects: fieldSideEffects,
402
+ messages: fieldMessages,
403
+ },
404
+ }
405
+ }
304
406
  return { ok: true }
305
407
  },
306
408
  [widgets]
@@ -0,0 +1,24 @@
1
+ "use client"
2
+
3
+ import { useAppEvent } from './useAppEvent'
4
+ import { flash } from '../FlashMessages'
5
+
6
+ /**
7
+ * Global SSE Event Indicator
8
+ *
9
+ * Mount once in AppShell to provide visible feedback when server events
10
+ * arrive via the DOM Event Bridge. Shows a flash message for every
11
+ * broadcast event received.
12
+ *
13
+ * This component renders nothing — it only listens and triggers flash messages.
14
+ */
15
+ export function SseEventIndicator(): null {
16
+ useAppEvent('*', (event) => {
17
+ const parts = event.id.split('.')
18
+ const module = parts[0] ?? ''
19
+ const action = parts[parts.length - 1] ?? 'event'
20
+ flash(`[SSE] ${module}: ${action} (${event.id})`, 'info')
21
+ })
22
+
23
+ return null
24
+ }
@@ -0,0 +1,58 @@
1
+ type Subscriber = (value: unknown) => void
2
+
3
+ export interface WidgetSharedState {
4
+ get<T>(key: string): T | undefined
5
+ set<T>(key: string, value: T): void
6
+ subscribe(key: string, handler: Subscriber): () => void
7
+ }
8
+
9
+ class NamespacedWidgetSharedState implements WidgetSharedState {
10
+ private readonly values = new Map<string, unknown>()
11
+ private readonly subscribers = new Map<string, Set<Subscriber>>()
12
+
13
+ constructor(private readonly namespace: string) {}
14
+
15
+ get<T>(key: string): T | undefined {
16
+ return this.values.get(this.toScopedKey(key)) as T | undefined
17
+ }
18
+
19
+ set<T>(key: string, value: T): void {
20
+ const scopedKey = this.toScopedKey(key)
21
+ this.values.set(scopedKey, value)
22
+ const handlers = this.subscribers.get(scopedKey)
23
+ if (!handlers || handlers.size === 0) return
24
+ for (const handler of handlers) {
25
+ handler(value)
26
+ }
27
+ }
28
+
29
+ subscribe(key: string, handler: Subscriber): () => void {
30
+ const scopedKey = this.toScopedKey(key)
31
+ const handlers = this.subscribers.get(scopedKey) ?? new Set<Subscriber>()
32
+ handlers.add(handler)
33
+ this.subscribers.set(scopedKey, handlers)
34
+ return () => {
35
+ const current = this.subscribers.get(scopedKey)
36
+ if (!current) return
37
+ current.delete(handler)
38
+ if (current.size === 0) {
39
+ this.subscribers.delete(scopedKey)
40
+ }
41
+ }
42
+ }
43
+
44
+ private toScopedKey(key: string): string {
45
+ return `${this.namespace}:${key}`
46
+ }
47
+ }
48
+
49
+ const storeByNamespace = new Map<string, WidgetSharedState>()
50
+
51
+ export function getWidgetSharedState(namespace: string): WidgetSharedState {
52
+ const normalized = namespace.trim().length > 0 ? namespace.trim() : 'global'
53
+ const existing = storeByNamespace.get(normalized)
54
+ if (existing) return existing
55
+ const created = new NamespacedWidgetSharedState(normalized)
56
+ storeByNamespace.set(normalized, created)
57
+ return created
58
+ }