@open-mercato/ui 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiChat.js.map +1 -1
  3. package/dist/ai/records/ActivityCard.js +1 -6
  4. package/dist/ai/records/ActivityCard.js.map +2 -2
  5. package/dist/ai/records/DealCard.js +2 -23
  6. package/dist/ai/records/DealCard.js.map +2 -2
  7. package/dist/ai/records/ProductCard.js +2 -17
  8. package/dist/ai/records/ProductCard.js.map +2 -2
  9. package/dist/ai/useAiChat.js.map +1 -1
  10. package/dist/backend/AppShell.js +15 -8
  11. package/dist/backend/AppShell.js.map +2 -2
  12. package/dist/backend/CrudForm.js.map +1 -1
  13. package/dist/backend/DataTable.js +10 -9
  14. package/dist/backend/DataTable.js.map +2 -2
  15. package/dist/backend/ProfileDropdown.js +62 -33
  16. package/dist/backend/ProfileDropdown.js.map +2 -2
  17. package/dist/backend/charts/KpiCard.js +22 -14
  18. package/dist/backend/charts/KpiCard.js.map +2 -2
  19. package/dist/backend/charts/Sparkline.js +75 -0
  20. package/dist/backend/charts/Sparkline.js.map +7 -0
  21. package/dist/backend/charts/index.js +4 -1
  22. package/dist/backend/charts/index.js.map +2 -2
  23. package/dist/backend/dashboard/DashboardScreen.js +1 -2
  24. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  25. package/dist/backend/injection/InjectionSpot.js +23 -6
  26. package/dist/backend/injection/InjectionSpot.js.map +2 -2
  27. package/dist/backend/injection/useGuardedMutation.js.map +1 -1
  28. package/dist/backend/utils/optimisticLock.js.map +1 -1
  29. package/dist/primitives/avatar.js +2 -2
  30. package/dist/primitives/avatar.js.map +2 -2
  31. package/dist/primitives/pagination.js +3 -3
  32. package/dist/primitives/pagination.js.map +2 -2
  33. package/dist/utils/format.js +27 -0
  34. package/dist/utils/format.js.map +7 -0
  35. package/package.json +9 -6
  36. package/src/ai/AiChat.tsx +1 -1
  37. package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
  38. package/src/ai/records/ActivityCard.tsx +1 -7
  39. package/src/ai/records/DealCard.tsx +2 -26
  40. package/src/ai/records/ProductCard.tsx +2 -19
  41. package/src/ai/useAiChat.ts +1 -1
  42. package/src/backend/AppShell.tsx +18 -8
  43. package/src/backend/CrudForm.tsx +1 -1
  44. package/src/backend/DataTable.tsx +18 -11
  45. package/src/backend/ProfileDropdown.tsx +35 -3
  46. package/src/backend/__tests__/AppShell.test.tsx +61 -1
  47. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
  48. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +99 -0
  49. package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
  50. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
  51. package/src/backend/charts/KpiCard.tsx +22 -12
  52. package/src/backend/charts/Sparkline.tsx +87 -0
  53. package/src/backend/charts/index.ts +2 -1
  54. package/src/backend/dashboard/DashboardScreen.tsx +1 -2
  55. package/src/backend/injection/InjectionSpot.tsx +27 -7
  56. package/src/backend/injection/__tests__/InjectionSpot.remount.test.tsx +140 -0
  57. package/src/backend/injection/useGuardedMutation.ts +1 -1
  58. package/src/backend/utils/optimisticLock.ts +1 -1
  59. package/src/primitives/__tests__/alert.test.tsx +16 -0
  60. package/src/primitives/__tests__/avatar.test.tsx +36 -1
  61. package/src/primitives/__tests__/pagination.test.tsx +31 -0
  62. package/src/primitives/avatar.tsx +8 -2
  63. package/src/primitives/pagination.tsx +3 -3
  64. package/src/utils/__tests__/format.test.ts +58 -0
  65. package/src/utils/format.ts +40 -0
@@ -19,7 +19,13 @@ jest.mock('next/link', () => {
19
19
  ))
20
20
  })
21
21
 
22
- jest.mock('next/image', () => (props: any) => <img alt={props.alt} {...props} />)
22
+ jest.mock('next/image', () => {
23
+ const React = require('react')
24
+ return (props: any) => {
25
+ const { unoptimized, ...rest } = props
26
+ return <img alt={rest.alt} data-unoptimized={unoptimized ? 'true' : 'false'} {...rest} />
27
+ }
28
+ })
23
29
 
24
30
  jest.mock('next/navigation', () => ({
25
31
  usePathname: () => mockPathname,
@@ -201,6 +207,60 @@ describe('AppShell', () => {
201
207
  )
202
208
  })
203
209
 
210
+ it('uses backend chrome brand logo when the selected organization has one', async () => {
211
+ const previousFetch = global.fetch
212
+ const previousWindowFetch = window.fetch
213
+ const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
214
+ const fetchMock = jest.fn().mockResolvedValue(
215
+ new Response(JSON.stringify({
216
+ brand: {
217
+ name: 'Acme',
218
+ logo: {
219
+ src: '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
220
+ alt: 'Acme logo',
221
+ },
222
+ },
223
+ groups,
224
+ settingsSections: [],
225
+ settingsPathPrefixes: [],
226
+ profileSections: [],
227
+ profilePathPrefixes: [],
228
+ grantedFeatures: [],
229
+ roles: [],
230
+ }), { status: 200, headers: { 'content-type': 'application/json' } }),
231
+ ) as typeof fetch
232
+ global.fetch = fetchMock
233
+ window.fetch = fetchMock
234
+ ;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = fetchMock
235
+
236
+ try {
237
+ renderWithProviders(
238
+ <AppShell
239
+ email="demo@example.com"
240
+ groups={[]}
241
+ adminNavApi="/api/auth/admin/nav-brand-logo"
242
+ >
243
+ <div>Child content</div>
244
+ </AppShell>,
245
+ { dict },
246
+ )
247
+
248
+ await waitFor(() => {
249
+ const logo = screen.getByAltText('Acme logo')
250
+ expect(logo).toHaveAttribute(
251
+ 'src',
252
+ '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
253
+ )
254
+ expect(logo).toHaveAttribute('data-unoptimized', 'true')
255
+ })
256
+ expect(screen.getByText('Acme')).toBeInTheDocument()
257
+ } finally {
258
+ global.fetch = previousFetch
259
+ window.fetch = previousWindowFetch
260
+ ;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = previousOriginalFetch
261
+ }
262
+ })
263
+
204
264
  it('renders nested settings links when settings parent route is active', async () => {
205
265
  mockPathname = '/backend/entities/user'
206
266
 
@@ -58,6 +58,9 @@ import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
58
58
 
59
59
  const dict = {
60
60
  'ui.forms.actions.save': 'Save',
61
+ 'ui.forms.actions.delete': 'Delete',
62
+ 'ui.forms.flash.deleteSuccess': 'Record deleted.',
63
+ 'ui.forms.flash.deleteError': 'Failed to delete record.',
61
64
  'ui.forms.flash.recordModified': 'This record was modified by someone else. Refresh and try again.',
62
65
  }
63
66
 
@@ -171,6 +174,55 @@ describe('CrudForm — optimisticLockUpdatedAt prop wiring', () => {
171
174
  'error',
172
175
  )
173
176
  })
177
+
178
+ it('delete: a 409 conflict surfaces the conflict bar and never shows a success toast (regression #2409)', async () => {
179
+ // Regression guard for #2409: when an optimistic-lock conflict (409) is
180
+ // thrown from onDelete, CrudForm must route it to the unified conflict bar
181
+ // and MUST NOT flash a "Record deleted." success toast. A previous version
182
+ // of the planner Availability Schedule page swallowed the delete error, so
183
+ // CrudForm treated the failed delete as a success and showed a false toast.
184
+ dismissRecordConflict()
185
+ const conflict = Object.assign(new Error('record_modified'), {
186
+ status: 409,
187
+ error: 'record_modified',
188
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
189
+ currentUpdatedAt: '2026-05-25T08:42:19.000Z',
190
+ expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
191
+ })
192
+ const onSubmit = jest.fn(async () => undefined)
193
+ const onDelete = jest.fn(async () => {
194
+ throw conflict
195
+ })
196
+ const { container } = renderWithProviders(
197
+ <CrudForm<{ displayName: string; id?: string }>
198
+ fields={fields}
199
+ initialValues={{ id: 'rec-1', displayName: 'old' }}
200
+ onSubmit={onSubmit}
201
+ onDelete={onDelete}
202
+ optimisticLockUpdatedAt="2026-05-25T08:42:18.000Z"
203
+ />,
204
+ { dict },
205
+ )
206
+
207
+ const deleteButton = Array.from(container.querySelectorAll('button')).find(
208
+ (button) => button.textContent?.trim() === 'Delete',
209
+ ) as HTMLButtonElement
210
+ expect(deleteButton).toBeTruthy()
211
+
212
+ await act(async () => {
213
+ fireEvent.click(deleteButton)
214
+ })
215
+
216
+ await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1))
217
+ await waitFor(() => {
218
+ const entry = getRecordConflictForTest()
219
+ expect(entry).not.toBeNull()
220
+ expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
221
+ })
222
+ // The false success toast is the actual bug — it must never fire on a 409.
223
+ expect(flashMock).not.toHaveBeenCalledWith('Record deleted.', 'success')
224
+ expect(flashMock).not.toHaveBeenCalledWith(expect.anything(), 'success')
225
+ })
174
226
  })
175
227
 
176
228
  type AutoFormValues = { displayName: string; id?: string; updatedAt?: string | null; updated_at?: string | null }
@@ -0,0 +1,99 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { DataTable } from '../DataTable'
4
+ import type { ColumnDef } from '@tanstack/react-table'
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
7
+ import { render } from '@testing-library/react'
8
+ import { RowActions } from '../RowActions'
9
+
10
+ jest.mock('next/navigation', () => ({
11
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
12
+ }))
13
+
14
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
15
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
16
+ }))
17
+
18
+ type Row = { id: string; title: string; status: string }
19
+
20
+ const tokensOf = (el: Element | null): string[] =>
21
+ (el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
22
+
23
+ // Pinned columns must never consume the viewport on phones: an unconditionally
24
+ // `sticky` first column (often ~300px wide) plus a sticky actions column can
25
+ // exceed a small screen entirely, leaving the middle columns scrolling
26
+ // invisibly underneath with no reachable window. Pinning (position, offsets,
27
+ // z-index, opaque background, edge shadows) must therefore apply only from the
28
+ // `md` breakpoint up, so narrow viewports fall back to the documented plain
29
+ // horizontal-scroll behavior where every column can be swiped into view.
30
+ describe('DataTable sticky columns are viewport-gated', () => {
31
+ function renderStickyTable() {
32
+ const columns: ColumnDef<Row>[] = [
33
+ { accessorKey: 'title', header: 'Title' },
34
+ { accessorKey: 'status', header: 'Status' },
35
+ ]
36
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
37
+ const result = render(
38
+ <QueryClientProvider client={queryClient}>
39
+ <I18nProvider locale="en" dict={{}}>
40
+ <DataTable
41
+ columns={columns}
42
+ data={[{ id: '1', title: 'Solar rollout', status: 'open' }]}
43
+ stickyFirstColumn
44
+ stickyActionsColumn
45
+ rowActions={() => (
46
+ <RowActions items={[{ id: 'edit', label: 'Edit', onSelect: () => {} }]} />
47
+ )}
48
+ />
49
+ </I18nProvider>
50
+ </QueryClientProvider>,
51
+ )
52
+ return { ...result, queryClient }
53
+ }
54
+
55
+ it('pins the first data column only from md up', () => {
56
+ const { container, queryClient } = renderStickyTable()
57
+ try {
58
+ const headerCells = container.querySelectorAll('thead th')
59
+ const firstHeader = headerCells[0]
60
+ const firstBodyCell = container.querySelector('tbody tr td')
61
+
62
+ for (const cell of [firstHeader, firstBodyCell]) {
63
+ const tokens = tokensOf(cell)
64
+ expect(tokens).toEqual(
65
+ expect.arrayContaining(['md:sticky', 'md:left-0', 'md:bg-background', 'md:after:absolute']),
66
+ )
67
+ expect(tokens).not.toContain('sticky')
68
+ expect(tokens).not.toContain('left-0')
69
+ expect(tokens).not.toContain('bg-background')
70
+ expect(tokens).not.toContain('after:absolute')
71
+ }
72
+ } finally {
73
+ queryClient.clear()
74
+ }
75
+ })
76
+
77
+ it('pins the actions column only from md up', () => {
78
+ const { container, queryClient } = renderStickyTable()
79
+ try {
80
+ const headerCells = container.querySelectorAll('thead th')
81
+ const actionsHeader = headerCells[headerCells.length - 1]
82
+ const actionsBodyCell = container.querySelector('tbody tr td[data-actions-cell]')
83
+ expect(actionsBodyCell).not.toBeNull()
84
+
85
+ for (const cell of [actionsHeader, actionsBodyCell]) {
86
+ const tokens = tokensOf(cell)
87
+ expect(tokens).toEqual(
88
+ expect.arrayContaining(['md:sticky', 'md:right-0', 'md:bg-background', 'md:before:absolute']),
89
+ )
90
+ expect(tokens).not.toContain('sticky')
91
+ expect(tokens).not.toContain('right-0')
92
+ expect(tokens).not.toContain('bg-background')
93
+ expect(tokens).not.toContain('before:absolute')
94
+ }
95
+ } finally {
96
+ queryClient.clear()
97
+ }
98
+ })
99
+ })
@@ -0,0 +1,59 @@
1
+ import * as React from 'react'
2
+ import { fireEvent, screen } from '@testing-library/react'
3
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
4
+
5
+ jest.mock('next/link', () => {
6
+ const MockLink = ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => (
7
+ <a href={href} {...rest}>{children}</a>
8
+ )
9
+ MockLink.displayName = 'MockLink'
10
+ return MockLink
11
+ })
12
+
13
+ jest.mock('../injection/InjectionSpot', () => ({
14
+ InjectionSpot: () => null,
15
+ }))
16
+
17
+ jest.mock('../injection/useInjectedMenuItems', () => ({
18
+ useInjectedMenuItems: () => ({ items: [], isLoading: false }),
19
+ }))
20
+
21
+ jest.mock('@open-mercato/ui/theme', () => ({
22
+ useTheme: () => ({ theme: 'light', resolvedTheme: 'light', setTheme: jest.fn() }),
23
+ }))
24
+
25
+ import { ProfileDropdown } from '../ProfileDropdown'
26
+
27
+ describe('ProfileDropdown', () => {
28
+ it('does not render the menu until the trigger is clicked', () => {
29
+ renderWithProviders(<ProfileDropdown email="user@example.com" />)
30
+ expect(screen.queryByTestId('profile-dropdown')).not.toBeInTheDocument()
31
+ })
32
+
33
+ it('renders the open menu in a body portal so it escapes the header stacking context', () => {
34
+ renderWithProviders(<ProfileDropdown email="user@example.com" />)
35
+
36
+ fireEvent.click(screen.getByTestId('profile-dropdown-trigger'))
37
+
38
+ const menu = screen.getByTestId('profile-dropdown')
39
+ expect(menu).toBeInTheDocument()
40
+ // createPortal mounts the menu directly under document.body, outside the
41
+ // sticky header's backdrop-blur stacking context (regression: issue #2941).
42
+ expect(menu.parentElement).toBe(document.body)
43
+ // It must use fixed positioning + the popover layer rather than being
44
+ // absolutely positioned inside the header.
45
+ expect(menu.className).toContain('fixed')
46
+ expect(menu.className).toContain('z-popover')
47
+ expect(menu.className).not.toContain('absolute')
48
+ })
49
+
50
+ it('closes the menu (unmounting the portal) on Escape', () => {
51
+ renderWithProviders(<ProfileDropdown email="user@example.com" />)
52
+
53
+ fireEvent.click(screen.getByTestId('profile-dropdown-trigger'))
54
+ expect(screen.getByTestId('profile-dropdown')).toBeInTheDocument()
55
+
56
+ fireEvent.keyDown(document, { key: 'Escape' })
57
+ expect(screen.queryByTestId('profile-dropdown')).not.toBeInTheDocument()
58
+ })
59
+ })
@@ -123,4 +123,12 @@ describe('heavy libraries are lazy-loaded', () => {
123
123
  const source = read('packages/ui/src/backend/schedule/ScheduleCalendar.tsx')
124
124
  expect(source).toMatch(/import\s+['"]react-big-calendar\/lib\/css\/react-big-calendar\.css['"]/)
125
125
  })
126
+
127
+ it('AttachmentContentPreview does not statically import react-markdown or remark-gfm', () => {
128
+ const source = read(
129
+ 'packages/core/src/modules/attachments/components/AttachmentContentPreview.tsx',
130
+ )
131
+ expect(source).not.toMatch(/from\s+['"]react-markdown['"]/)
132
+ expect(source).not.toMatch(/from\s+['"]remark-gfm['"]/)
133
+ })
126
134
  })
@@ -20,6 +20,8 @@ export type KpiCardProps = {
20
20
  suffix?: string
21
21
  className?: string
22
22
  headerAction?: React.ReactNode
23
+ footer?: React.ReactNode
24
+ titleClassName?: string
23
25
  }
24
26
 
25
27
  function defaultFormatValue(value: number): string {
@@ -32,17 +34,21 @@ function defaultFormatValue(value: number): string {
32
34
  return value.toLocaleString(undefined, { maximumFractionDigits: 2 })
33
35
  }
34
36
 
35
- function formatPercentageChange(value: number): string {
36
- const formatted = Math.abs(value).toFixed(1)
37
- return `${formatted}%`
37
+ function formatPercentageChange(value: number, unit: string = '%'): string {
38
+ const abs = Math.abs(value)
39
+ const formatted = Number.isInteger(abs) ? String(abs) : abs.toFixed(1)
40
+ return `${formatted}${unit}`
38
41
  }
39
42
 
40
43
  type BadgeDeltaProps = {
41
44
  direction: 'up' | 'down' | 'unchanged'
42
45
  value: number
46
+ unit?: string
47
+ className?: string
48
+ title?: string
43
49
  }
44
50
 
45
- function BadgeDelta({ direction, value }: BadgeDeltaProps) {
51
+ function BadgeDelta({ direction, value, unit = '%', className = '', title = 'Compared to previous period' }: BadgeDeltaProps) {
46
52
  const baseClasses = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium'
47
53
 
48
54
  const directionClasses = {
@@ -71,15 +77,17 @@ function BadgeDelta({ direction, value }: BadgeDeltaProps) {
71
77
 
72
78
  return (
73
79
  <span
74
- className={`${baseClasses} ${directionClasses[direction]}`}
75
- title="Compared to previous period"
80
+ className={`${baseClasses} ${directionClasses[direction]}${className ? ` ${className}` : ''}`}
81
+ title={title}
76
82
  >
77
83
  {icons[direction]}
78
- {formatPercentageChange(value)}
84
+ {formatPercentageChange(value, unit)}
79
85
  </span>
80
86
  )
81
87
  }
82
88
 
89
+ export const DeltaBadge = BadgeDelta
90
+
83
91
  export function KpiCard({
84
92
  title,
85
93
  value,
@@ -92,13 +100,15 @@ export function KpiCard({
92
100
  suffix = '',
93
101
  className = '',
94
102
  headerAction,
103
+ footer,
104
+ titleClassName,
95
105
  }: KpiCardProps) {
96
106
  const hasWrapper = !!title
97
107
  const wrapperClass = hasWrapper ? `rounded-lg border bg-card p-4 ${className}` : className
98
108
 
99
109
  const headerRow = (title || headerAction) ? (
100
110
  <div className="flex items-center justify-between gap-2 mb-2">
101
- {title && <p className="text-sm font-medium text-muted-foreground">{title}</p>}
111
+ {title && <p className={titleClassName ?? 'text-sm font-medium text-muted-foreground'}>{title}</p>}
102
112
  {headerAction}
103
113
  </div>
104
114
  ) : null
@@ -136,10 +146,9 @@ export function KpiCard({
136
146
  <div className={wrapperClass}>
137
147
  {headerRow}
138
148
  <div className="flex items-baseline gap-3">
139
- <p className="text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">
140
- {prefix}
141
- {formatValue(value)}
142
- {suffix}
149
+ <p className="flex items-baseline gap-1.5 text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">
150
+ <span>{prefix}{formatValue(value)}</span>
151
+ {suffix ? <span className="text-sm font-medium text-muted-foreground">{suffix}</span> : null}
143
152
  </p>
144
153
  {trend && (
145
154
  <BadgeDelta direction={trend.direction} value={trend.value} />
@@ -148,6 +157,7 @@ export function KpiCard({
148
157
  {trend && comparisonLabel && (
149
158
  <p className="mt-1 text-xs text-muted-foreground">{comparisonLabel}</p>
150
159
  )}
160
+ {footer != null && <div className="mt-3">{footer}</div>}
151
161
  </div>
152
162
  )
153
163
  }
@@ -0,0 +1,87 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+
5
+ export type SparklineProps = {
6
+ values: number[]
7
+ ariaLabel: string
8
+ className?: string
9
+ width?: number
10
+ height?: number
11
+ }
12
+
13
+ function buildPoints(values: number[], width: number, height: number): Array<{ x: number; y: number }> {
14
+ const count = values.length
15
+ const padding = 2
16
+ const usableHeight = Math.max(height - padding * 2, 0)
17
+
18
+ if (count === 1) {
19
+ return [{ x: width / 2, y: height / 2 }]
20
+ }
21
+
22
+ let min = values[0]
23
+ let max = values[0]
24
+ for (const value of values) {
25
+ if (value < min) min = value
26
+ if (value > max) max = value
27
+ }
28
+ const range = max - min
29
+
30
+ const stepX = count > 1 ? width / (count - 1) : 0
31
+
32
+ return values.map((value, index) => {
33
+ const x = stepX * index
34
+ const ratio = range === 0 ? 0.5 : (value - min) / range
35
+ const y = padding + (1 - ratio) * usableHeight
36
+ return { x, y }
37
+ })
38
+ }
39
+
40
+ function toPath(points: Array<{ x: number; y: number }>): string {
41
+ return points
42
+ .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
43
+ .join(' ')
44
+ }
45
+
46
+ export function Sparkline({
47
+ values,
48
+ ariaLabel,
49
+ className = '',
50
+ width = 96,
51
+ height = 28,
52
+ }: SparklineProps) {
53
+ if (!values || values.length === 0) {
54
+ return null
55
+ }
56
+
57
+ const points = buildPoints(values, width, height)
58
+ const linePath = toPath(points)
59
+
60
+ const firstPoint = points[0]
61
+ const lastPoint = points[points.length - 1]
62
+ const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${height} L ${firstPoint.x.toFixed(2)} ${height} Z`
63
+
64
+ return (
65
+ <svg
66
+ role="img"
67
+ aria-label={ariaLabel}
68
+ width={width}
69
+ height={height}
70
+ viewBox={`0 0 ${width} ${height}`}
71
+ preserveAspectRatio="none"
72
+ className={className}
73
+ >
74
+ <path d={areaPath} fill="currentColor" fillOpacity={0.12} stroke="none" />
75
+ <path
76
+ d={linePath}
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth={1.5}
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ />
83
+ </svg>
84
+ )
85
+ }
86
+
87
+ export default Sparkline
@@ -1,4 +1,5 @@
1
- export { KpiCard, type KpiCardProps, type KpiTrend } from './KpiCard'
1
+ export { KpiCard, DeltaBadge, type KpiCardProps, type KpiTrend } from './KpiCard'
2
+ export { Sparkline, type SparklineProps } from './Sparkline'
2
3
  export { BarChart, type BarChartProps, type BarChartDataItem } from './BarChart'
3
4
  export { LineChart, type LineChartProps, type LineChartDataItem } from './LineChart'
4
5
  export { PieChart, type PieChartProps, type PieChartDataItem } from './PieChart'
@@ -10,7 +10,7 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
10
10
  import { getDashboardWidgets, loadDashboardWidgetModule } from './widgetRegistry'
11
11
  import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
12
12
  import { cn } from '@open-mercato/shared/lib/utils'
13
- import { GripVertical, Info, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
13
+ import { GripVertical, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
14
14
  import { useT } from '@open-mercato/shared/lib/i18n/context'
15
15
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
16
16
  import { InjectionSpot } from '../injection/InjectionSpot'
@@ -366,7 +366,6 @@ export function DashboardScreen() {
366
366
  if (!hasRegisteredWidgets && layout.length === 0) {
367
367
  return (
368
368
  <Alert variant="info">
369
- <Info className="h-4 w-4" aria-hidden />
370
369
  <AlertTitle>{t('dashboard.empty.noWidgets.title', 'No dashboard widgets yet')}</AlertTitle>
371
370
  <AlertDescription>
372
371
  {t(
@@ -76,6 +76,15 @@ export function useInjectionWidgets<TContext = unknown>(
76
76
  const [error, setError] = React.useState<string | null>(null)
77
77
  const [registryVersion, setRegistryVersion] = React.useState(() => getInjectionRegistryVersion())
78
78
  const loadedRef = React.useRef(false)
79
+ const contextRef = React.useRef(options?.context)
80
+ const triggerOnLoadRef = React.useRef(options?.triggerOnLoad)
81
+ const onEventRef = React.useRef(options?.onEvent)
82
+
83
+ React.useEffect(() => {
84
+ contextRef.current = options?.context
85
+ triggerOnLoadRef.current = options?.triggerOnLoad
86
+ onEventRef.current = options?.onEvent
87
+ })
79
88
 
80
89
  React.useEffect(() => {
81
90
  return subscribeToInjectionRegistryChanges(() => {
@@ -105,16 +114,16 @@ export function useInjectionWidgets<TContext = unknown>(
105
114
  placement: w.placement,
106
115
  }))
107
116
  setWidgets(widgetList)
108
-
117
+
109
118
  // Trigger onLoad for all widgets
110
- if (!loadedRef.current && options?.triggerOnLoad) {
119
+ if (!loadedRef.current && triggerOnLoadRef.current) {
111
120
  loadedRef.current = true
112
121
  for (const widget of widgetList) {
113
122
  if (widget.module.eventHandlers?.onLoad) {
114
123
  try {
115
- const widgetContext = injectSharedStateIntoContext(options.context as TContext, widget.moduleId)
124
+ const widgetContext = injectSharedStateIntoContext(contextRef.current as TContext, widget.moduleId)
116
125
  await widget.module.eventHandlers.onLoad(widgetContext)
117
- options.onEvent?.('onLoad', widget.widgetId)
126
+ onEventRef.current?.('onLoad', widget.widgetId)
118
127
  } catch (err) {
119
128
  console.error(`[InjectionSpot] Error in onLoad for widget ${widget.widgetId}:`, err)
120
129
  }
@@ -133,7 +142,9 @@ export function useInjectionWidgets<TContext = unknown>(
133
142
  return () => {
134
143
  mounted = false
135
144
  }
136
- }, [spotId, options?.context, options?.triggerOnLoad, options?.onEvent, registryVersion])
145
+ // context/triggerOnLoad/onEvent are read from refs so only a real registry-version bump reloads the spot
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
147
+ }, [spotId, registryVersion])
137
148
 
138
149
  return { widgets, loading, error }
139
150
  }
@@ -148,16 +159,25 @@ export function InjectionSpot<TContext = unknown, TData = unknown>({
148
159
  widgetsOverride,
149
160
  }: InjectionSpotProps<TContext, TData>) {
150
161
  const useSpotId = widgetsOverride ? null : spotId
162
+ const onEventRef = React.useRef(onEvent)
163
+ React.useEffect(() => {
164
+ onEventRef.current = onEvent
165
+ })
166
+ const hasOnEvent = Boolean(onEvent)
167
+ const stableOnEvent = React.useMemo(
168
+ () => (hasOnEvent ? (event: 'onLoad', id: string) => onEventRef.current?.(event, id) : undefined),
169
+ [hasOnEvent],
170
+ )
151
171
  const { widgets, loading, error } = useInjectionWidgets<TContext>(useSpotId, {
152
172
  context,
153
173
  triggerOnLoad: !widgetsOverride,
154
- onEvent: onEvent ? (event, id) => onEvent(event, id) : undefined,
174
+ onEvent: stableOnEvent,
155
175
  })
156
176
  const effectiveWidgets = widgetsOverride ?? widgets
157
177
  const effectiveLoading = widgetsOverride ? false : loading
158
178
  const effectiveError = widgetsOverride ? null : error
159
179
 
160
- if (effectiveLoading) {
180
+ if (effectiveLoading && effectiveWidgets.length === 0) {
161
181
  return null
162
182
  }
163
183