@open-mercato/ui 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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 (167) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -4
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +54 -2
  5. package/dist/ai/AiChat.js +2 -21
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/backend/AppShell.js +4 -43
  8. package/dist/backend/AppShell.js.map +2 -2
  9. package/dist/backend/AuthSessionGuard.js +0 -14
  10. package/dist/backend/AuthSessionGuard.js.map +2 -2
  11. package/dist/backend/BackendChromeProvider.js +1 -6
  12. package/dist/backend/BackendChromeProvider.js.map +2 -2
  13. package/dist/backend/CrudForm.js +4 -6
  14. package/dist/backend/CrudForm.js.map +2 -2
  15. package/dist/backend/DataTable.js +18 -20
  16. package/dist/backend/DataTable.js.map +2 -2
  17. package/dist/backend/JsonBuilder.js +3 -3
  18. package/dist/backend/JsonBuilder.js.map +2 -2
  19. package/dist/backend/NextStepCallout.js +4 -4
  20. package/dist/backend/NextStepCallout.js.map +1 -1
  21. package/dist/backend/ProfileDropdown.js +1 -2
  22. package/dist/backend/ProfileDropdown.js.map +2 -2
  23. package/dist/backend/RowActions.js +2 -2
  24. package/dist/backend/RowActions.js.map +2 -2
  25. package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -13
  26. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  27. package/dist/backend/crud/useGroupOrder.js +21 -14
  28. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  29. package/dist/backend/detail/AddressTiles.js +5 -4
  30. package/dist/backend/detail/AddressTiles.js.map +2 -2
  31. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  32. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  33. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  34. package/dist/backend/detail/AttachmentsSection.js +1 -1
  35. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  36. package/dist/backend/detail/NotesSection.js +1 -1
  37. package/dist/backend/detail/NotesSection.js.map +2 -2
  38. package/dist/backend/detail/TagsSection.js +1 -1
  39. package/dist/backend/detail/TagsSection.js.map +2 -2
  40. package/dist/backend/forms/FormHeader.js +2 -1
  41. package/dist/backend/forms/FormHeader.js.map +2 -2
  42. package/dist/backend/icons/lucideRegistry.generated.js +25 -1
  43. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  44. package/dist/backend/icons/lucideRegistry.js +3 -3
  45. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  46. package/dist/backend/injection/spotIds.js +18 -23
  47. package/dist/backend/injection/spotIds.js.map +2 -2
  48. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  49. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  50. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  51. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  52. package/dist/backend/schedule/ScheduleToolbar.js +70 -87
  53. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  54. package/dist/backend/sidebar/SidebarCustomizationEditor.js +2 -30
  55. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  56. package/dist/backend/sidebar/customization-helpers.js +0 -30
  57. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  58. package/dist/backend/utils/api.js +13 -3
  59. package/dist/backend/utils/api.js.map +2 -2
  60. package/dist/backend/utils/apiCall.js +1 -21
  61. package/dist/backend/utils/apiCall.js.map +2 -2
  62. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  63. package/dist/backend/utils/customFieldForms.js +9 -10
  64. package/dist/backend/utils/customFieldForms.js.map +2 -2
  65. package/dist/backend/utils/nav.js +2 -5
  66. package/dist/backend/utils/nav.js.map +2 -2
  67. package/dist/frontend/LanguageSwitcher.js +1 -2
  68. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  69. package/dist/portal/PortalContext.js +1 -1
  70. package/dist/portal/PortalContext.js.map +2 -2
  71. package/dist/portal/hooks/usePortalEventBridge.js +0 -6
  72. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  73. package/dist/portal/hooks/usePortalNotifications.js +4 -42
  74. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  75. package/dist/portal/utils/nav.js +3 -2
  76. package/dist/portal/utils/nav.js.map +2 -2
  77. package/dist/primitives/button.js +4 -9
  78. package/dist/primitives/button.js.map +2 -2
  79. package/jest.config.cjs +1 -2
  80. package/package.json +18 -21
  81. package/src/ai/AiChat.tsx +1 -30
  82. package/src/ai/__tests__/AiChat.test.tsx +0 -31
  83. package/src/backend/AppShell.tsx +4 -55
  84. package/src/backend/AuthSessionGuard.tsx +0 -29
  85. package/src/backend/BackendChromeProvider.tsx +1 -19
  86. package/src/backend/CrudForm.tsx +4 -18
  87. package/src/backend/DataTable.tsx +19 -36
  88. package/src/backend/JsonBuilder.tsx +3 -3
  89. package/src/backend/NextStepCallout.tsx +4 -4
  90. package/src/backend/ProfileDropdown.tsx +0 -1
  91. package/src/backend/RowActions.tsx +2 -2
  92. package/src/backend/__tests__/AppShell.test.tsx +7 -65
  93. package/src/backend/__tests__/AuthSessionGuard.test.tsx +0 -52
  94. package/src/backend/__tests__/BackendChromeProvider.test.tsx +2 -98
  95. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +1 -62
  96. package/src/backend/__tests__/nav-utils.test.ts +0 -139
  97. package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -16
  98. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +1 -68
  99. package/src/backend/crud/__tests__/useGroupOrder.test.ts +1 -39
  100. package/src/backend/crud/useGroupOrder.ts +25 -30
  101. package/src/backend/detail/AddressTiles.tsx +6 -3
  102. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  103. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  104. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  105. package/src/backend/detail/NotesSection.tsx +1 -1
  106. package/src/backend/detail/TagsSection.tsx +1 -1
  107. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +0 -21
  108. package/src/backend/forms/FormHeader.tsx +2 -1
  109. package/src/backend/icons/lucideRegistry.generated.tsx +37 -3
  110. package/src/backend/icons/lucideRegistry.ts +2 -2
  111. package/src/backend/injection/spotIds.ts +18 -23
  112. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  113. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  114. package/src/backend/schedule/ScheduleToolbar.tsx +68 -89
  115. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +3 -42
  116. package/src/backend/sidebar/customization-helpers.ts +0 -47
  117. package/src/backend/utils/__tests__/api.test.ts +27 -0
  118. package/src/backend/utils/__tests__/apiCall.test.ts +0 -61
  119. package/src/backend/utils/api.ts +20 -3
  120. package/src/backend/utils/apiCall.ts +1 -29
  121. package/src/backend/utils/customFieldDefs.ts +0 -1
  122. package/src/backend/utils/customFieldForms.ts +11 -15
  123. package/src/backend/utils/nav.ts +2 -22
  124. package/src/frontend/LanguageSwitcher.tsx +0 -1
  125. package/src/portal/PortalContext.tsx +1 -1
  126. package/src/portal/hooks/usePortalEventBridge.ts +0 -6
  127. package/src/portal/hooks/usePortalNotifications.ts +12 -55
  128. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  129. package/src/portal/utils/nav.ts +4 -3
  130. package/src/primitives/button.tsx +4 -10
  131. package/dist/backend/icons/lucideRegistryRuntime.js +0 -28
  132. package/dist/backend/icons/lucideRegistryRuntime.js.map +0 -7
  133. package/dist/backend/perspectiveState.js +0 -34
  134. package/dist/backend/perspectiveState.js.map +0 -7
  135. package/dist/backend/utils/fetchAllPages.js +0 -57
  136. package/dist/backend/utils/fetchAllPages.js.map +0 -7
  137. package/dist/portal/hooks/portalBridgeStatus.js +0 -25
  138. package/dist/portal/hooks/portalBridgeStatus.js.map +0 -7
  139. package/figma/alert.figma.tsx +0 -35
  140. package/figma/badge.figma.tsx +0 -33
  141. package/figma/button.figma.tsx +0 -33
  142. package/figma/checkbox.figma.tsx +0 -28
  143. package/figma/drawer.figma.tsx +0 -40
  144. package/figma/input-variants.figma.tsx +0 -58
  145. package/figma/radio.figma.tsx +0 -16
  146. package/figma/select.figma.tsx +0 -24
  147. package/figma/status-badge.figma.tsx +0 -24
  148. package/figma/switch.figma.tsx +0 -28
  149. package/figma/tabs.figma.tsx +0 -22
  150. package/figma/tag.figma.tsx +0 -27
  151. package/figma.config.json +0 -6
  152. package/scripts/lucideRegistrySource.cjs +0 -133
  153. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +0 -158
  154. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +0 -98
  155. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +0 -105
  156. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +0 -177
  157. package/src/backend/icons/lucideRegistryRuntime.tsx +0 -36
  158. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +0 -38
  159. package/src/backend/perspectiveState.ts +0 -44
  160. package/src/backend/sidebar/__tests__/group-visibility.test.ts +0 -131
  161. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +0 -95
  162. package/src/backend/utils/__tests__/fetchAllPages.test.ts +0 -103
  163. package/src/backend/utils/fetchAllPages.ts +0 -93
  164. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +0 -66
  165. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +0 -155
  166. package/src/portal/hooks/portalBridgeStatus.ts +0 -29
  167. package/src/primitives/__tests__/button.test.tsx +0 -70
@@ -75,25 +75,4 @@ describe('AttachmentAssignmentsEditor (metadata dialog)', () => {
75
75
  fireEvent.click(screen.getByRole('button', { name: 'Remove assignment' }))
76
76
  expect(screen.queryByPlaceholderText('catalog.product')).toBeNull()
77
77
  })
78
-
79
- it('keeps the surviving assignment values after removing a middle row', () => {
80
- render(
81
- <EditorHarness
82
- initial={[
83
- { type: 'catalog.product', id: 'p-1', href: '', label: '' },
84
- { type: 'sales.order', id: 'o-2', href: '', label: '' },
85
- { type: 'customers.person', id: 'c-3', href: '', label: '' },
86
- ]}
87
- />,
88
- )
89
-
90
- fireEvent.click(screen.getAllByRole('button', { name: labels.remove })[1])
91
-
92
- const typeInputs = screen.getAllByPlaceholderText('catalog.product') as HTMLInputElement[]
93
- const idInputs = screen.getAllByPlaceholderText('Record ID') as HTMLInputElement[]
94
- expect(typeInputs[0]).toHaveValue('catalog.product')
95
- expect(idInputs[0]).toHaveValue('p-1')
96
- expect(typeInputs[1]).toHaveValue('customers.person')
97
- expect(idInputs[1]).toHaveValue('c-3')
98
- })
99
78
  })
@@ -181,10 +181,11 @@ function DetailHeader({
181
181
  {onDelete ? (
182
182
  <Button
183
183
  type="button"
184
- variant="destructive"
184
+ variant="outline"
185
185
  size="sm"
186
186
  onClick={onDelete}
187
187
  disabled={isDeleting}
188
+ className="text-red-600 border-red-200 hover:bg-red-50 rounded"
188
189
  >
189
190
  {isDeleting ? (
190
191
  <Loader2 className="size-4 mr-2 animate-spin" />
@@ -2,7 +2,8 @@
2
2
  // AUTO-GENERATED by @open-mercato/ui build. Do not edit by hand.
3
3
  // Generated from discovered icon string usages across the repo.
4
4
 
5
- import type { LucideIcon } from 'lucide-react';
5
+ import type * as React from 'react'
6
+ import type { LucideIcon } from 'lucide-react'
6
7
  import {
7
8
  Activity,
8
9
  AlertCircle,
@@ -149,7 +150,7 @@ import {
149
150
  Wrench,
150
151
  X,
151
152
  XCircle,
152
- } from 'lucide-react';
153
+ } from 'lucide-react'
153
154
 
154
155
  export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
155
156
  'activity': Activity,
@@ -297,4 +298,37 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
297
298
  'wrench': Wrench,
298
299
  'x': X,
299
300
  'x-circle': XCircle,
300
- };
301
+ }
302
+
303
+ function normalizeKebabIconName(input: string): string {
304
+ const trimmed = input.trim()
305
+ if (!trimmed) return ''
306
+ const withoutPrefix = trimmed.startsWith('lucide:') ? trimmed.slice('lucide:'.length) : trimmed
307
+ if (!withoutPrefix) return ''
308
+ if (!withoutPrefix.includes('-') && !withoutPrefix.includes('_') && !withoutPrefix.includes(' ') && /[A-Z]/.test(withoutPrefix)) {
309
+ return withoutPrefix
310
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
311
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
312
+ .toLowerCase()
313
+ }
314
+ return withoutPrefix
315
+ .replace(/[_\s]+/g, '-')
316
+ .replace(/-+/g, '-')
317
+ .toLowerCase()
318
+ }
319
+
320
+ export function resolveRegisteredLucideIcon(name: string | undefined): LucideIcon | null {
321
+ if (!name) return null
322
+ const normalized = normalizeKebabIconName(name)
323
+ if (!normalized) return null
324
+ return (LUCIDE_ICON_REGISTRY as Record<string, LucideIcon | undefined>)[normalized] ?? null
325
+ }
326
+
327
+ export function resolveRegisteredLucideIconNode(
328
+ name: string | undefined,
329
+ className: string
330
+ ): React.ReactNode | null {
331
+ const Icon = resolveRegisteredLucideIcon(name)
332
+ if (!Icon) return null
333
+ return <Icon className={className} />
334
+ }
@@ -1,8 +1,8 @@
1
1
  export {
2
2
  resolveRegisteredLucideIcon,
3
3
  resolveRegisteredLucideIconNode,
4
- } from './lucideRegistryRuntime'
5
- export { LUCIDE_ICON_REGISTRY } from './lucideRegistry.generated'
4
+ LUCIDE_ICON_REGISTRY,
5
+ } from './lucideRegistry.generated'
6
6
 
7
7
  import type { LucideIcon } from 'lucide-react'
8
8
  import { LUCIDE_ICON_REGISTRY } from './lucideRegistry.generated'
@@ -1,8 +1,4 @@
1
1
  import type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'
2
- import {
3
- crudFormExtensionSpotId,
4
- dataTableExtensionSpotId,
5
- } from '@open-mercato/shared/modules/widgets/extension-points'
6
2
 
7
3
  export const BACKEND_RECORD_CURRENT_INJECTION_SPOT_ID: InjectionSpotId = 'backend:record:current'
8
4
  export const BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID: InjectionSpotId = 'backend:layout:top'
@@ -22,33 +18,32 @@ export const GLOBAL_HEADER_STATUS_INDICATORS_INJECTION_SPOT_ID: InjectionSpotId
22
18
 
23
19
  // Standardized pattern helpers
24
20
  export const CrudFormInjectionSpots = {
25
- base: (entityId: string): InjectionSpotId => crudFormExtensionSpotId(entityId),
26
- beforeFields: (entityId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, 'before-fields'),
27
- afterFields: (entityId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, 'after-fields'),
28
- header: (entityId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, 'header'),
29
- fields: (entityId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, 'fields'),
30
- footer: (entityId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, 'footer'),
31
- sidebar: (entityId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, 'sidebar'),
32
- group: (entityId: string, groupId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, `group:${groupId}`),
33
- fieldBefore: (entityId: string, fieldId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, `field:${fieldId}:before`),
34
- fieldAfter: (entityId: string, fieldId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, `field:${fieldId}:after`),
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`,
35
30
  } as const
36
31
 
37
32
  export const DataTableInjectionSpots = {
38
- header: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'header'),
39
- footer: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'footer'),
40
- toolbar: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'toolbar'),
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`,
41
36
  // Slot rendered immediately after the search input on the same row as the
42
37
  // FilterBar — intended for compact, icon-sized triggers (AI assistants,
43
38
  // saved view shortcuts, etc.). Hosts pass the resolved spot ID through to
44
39
  // FilterBar's `searchTrailing` prop. Stays empty when the table has no
45
40
  // search input.
46
- searchTrailing: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'search-trailing'),
47
- emptyState: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'empty-state'),
48
- columns: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'columns'),
49
- rowActions: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'row-actions'),
50
- bulkActions: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'bulk-actions'),
51
- filters: (tableId: string): InjectionSpotId => dataTableExtensionSpotId(tableId, 'filters'),
41
+ searchTrailing: (tableId: string): InjectionSpotId => `data-table:${tableId}:search-trailing`,
42
+ emptyState: (tableId: string): InjectionSpotId => `data-table:${tableId}:empty-state`,
43
+ columns: (tableId: string): InjectionSpotId => `data-table:${tableId}:columns`,
44
+ rowActions: (tableId: string): InjectionSpotId => `data-table:${tableId}:row-actions`,
45
+ bulkActions: (tableId: string): InjectionSpotId => `data-table:${tableId}:bulk-actions`,
46
+ filters: (tableId: string): InjectionSpotId => `data-table:${tableId}:filters`,
52
47
  } as const
53
48
 
54
49
  // Portal chrome spot IDs (FROZEN once shipped)
@@ -57,9 +57,9 @@ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallbac
57
57
  }
58
58
 
59
59
  function getKindStyles(kind: ScheduleItem['kind']): string {
60
- if (kind === 'event') return 'border-status-info-border bg-status-info-bg text-status-info-text'
61
- if (kind === 'exception') return 'border-status-warning-border bg-status-warning-bg text-status-warning-text'
62
- return 'border-status-success-border bg-status-success-bg text-status-success-text'
60
+ if (kind === 'event') return 'border-blue-500/40 bg-blue-500/10 text-blue-950'
61
+ if (kind === 'exception') return 'border-amber-500/40 bg-amber-500/10 text-amber-950'
62
+ return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-950'
63
63
  }
64
64
 
65
65
  export type ScheduleAgendaProps = {
@@ -57,9 +57,9 @@ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallbac
57
57
  }
58
58
 
59
59
  function getKindStyles(kind: ScheduleItem['kind']): string {
60
- if (kind === 'event') return 'border-status-info-border bg-status-info-bg text-status-info-text'
61
- if (kind === 'exception') return 'border-status-warning-border bg-status-warning-bg text-status-warning-text'
62
- return 'border-status-success-border bg-status-success-bg text-status-success-text'
60
+ if (kind === 'event') return 'border-blue-500/40 bg-blue-500/10 text-blue-950'
61
+ if (kind === 'exception') return 'border-amber-500/40 bg-amber-500/10 text-amber-950'
62
+ return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-950'
63
63
  }
64
64
 
65
65
  export type ScheduleGridProps = {
@@ -1,19 +1,8 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { DateRangePicker } from '../../primitives/date-range-picker'
5
- import { IconButton } from '../../primitives/icon-button'
6
- import {
7
- SegmentedControl,
8
- SegmentedControlItem,
9
- } from '../../primitives/segmented-control'
10
- import {
11
- Select,
12
- SelectContent,
13
- SelectItem,
14
- SelectTrigger,
15
- SelectValue,
16
- } from '../../primitives/select'
4
+ import { Button } from '../../primitives/button'
5
+ import { Input } from '../../primitives/input'
17
6
  import type { ScheduleRange, ScheduleViewMode } from './types'
18
7
  import { cn } from '@open-mercato/shared/lib/utils'
19
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -38,19 +27,17 @@ const VIEW_OPTIONS: Array<{ id: ScheduleViewMode; labelKey: string; fallback: st
38
27
  { id: 'agenda', labelKey: 'schedule.view.agenda', fallback: 'Agenda' },
39
28
  ]
40
29
 
41
- function getTimezoneOptions(current?: string): string[] {
42
- const options = new Set<string>()
43
- if (current) options.add(current)
44
- options.add('UTC')
45
- const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone
46
- if (resolved) options.add(resolved)
47
- const intlWithSupportedValues = Intl as typeof Intl & { supportedValuesOf?: (input: 'timeZone') => string[] }
48
- if (typeof intlWithSupportedValues.supportedValuesOf === 'function') {
49
- intlWithSupportedValues.supportedValuesOf('timeZone').forEach((timezone) => {
50
- if (timezone) options.add(timezone)
51
- })
52
- }
53
- return Array.from(options).sort((a, b) => a.localeCompare(b))
30
+ function formatDateInputValue(value: Date): string {
31
+ const year = value.getFullYear()
32
+ const month = String(value.getMonth() + 1).padStart(2, '0')
33
+ const day = String(value.getDate()).padStart(2, '0')
34
+ return `${year}-${month}-${day}`
35
+ }
36
+
37
+ function parseDateInputValue(value: string, fallback: Date): Date {
38
+ if (!value) return fallback
39
+ const next = new Date(`${value}T00:00:00`)
40
+ return Number.isNaN(next.getTime()) ? fallback : next
54
41
  }
55
42
 
56
43
  export type ScheduleToolbarProps = {
@@ -133,76 +120,68 @@ export function ScheduleToolbar({
133
120
  onRangeChange({ start: nextStart, end: endOfDay(addDays(nextStart, rangeLength - 1)) })
134
121
  }, [onRangeChange, range.start, rangeLength, view])
135
122
 
136
- const timezoneOptions = React.useMemo(() => getTimezoneOptions(timezone), [timezone])
137
-
138
123
  return (
139
- <div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border bg-card p-3', className)}>
140
- <SegmentedControl
141
- value={view}
142
- onValueChange={(value) => {
143
- const nextView = value as ScheduleViewMode
144
- if (nextView === view) return
145
- onViewChange(nextView)
146
- onRangeChange(deriveRangeForView(new Date(), nextView))
147
- }}
148
- aria-label={t('schedule.view.label', 'Schedule view')}
149
- className="shrink-0"
150
- >
124
+ <div className={cn('flex flex-col gap-3 rounded-xl border bg-card p-4 md:flex-row md:items-center md:justify-between', className)}>
125
+ <div className="flex flex-wrap items-center gap-2">
151
126
  {VIEW_OPTIONS.map((option) => (
152
- <SegmentedControlItem key={option.id} value={option.id}>
127
+ <Button
128
+ key={option.id}
129
+ variant={view === option.id ? 'default' : 'outline'}
130
+ size="sm"
131
+ onClick={() => {
132
+ if (option.id === view) return
133
+ onViewChange(option.id)
134
+ onRangeChange(deriveRangeForView(new Date(), option.id))
135
+ }}
136
+ >
153
137
  {t(option.labelKey, option.fallback)}
154
- </SegmentedControlItem>
138
+ </Button>
155
139
  ))}
156
- </SegmentedControl>
157
- <div className="flex shrink-0 items-center gap-1">
158
- <IconButton
159
- type="button"
160
- variant="outline"
161
- onClick={() => shiftRange('prev')}
162
- aria-label={t('schedule.range.prev', 'Previous')}
163
- >
140
+ </div>
141
+ <div className="flex flex-wrap items-center gap-2">
142
+ <Button type="button" variant="outline" size="sm" onClick={() => shiftRange('prev')} aria-label={t('schedule.range.prev', 'Previous')}>
164
143
  <ChevronLeft className="size-4" aria-hidden />
165
- </IconButton>
166
- <div className="min-w-0 whitespace-nowrap px-1 text-sm font-medium text-foreground">{rangeLabel}</div>
167
- <IconButton
168
- type="button"
169
- variant="outline"
170
- onClick={() => shiftRange('next')}
171
- aria-label={t('schedule.range.next', 'Next')}
172
- >
144
+ </Button>
145
+ <div className="text-sm font-medium text-foreground">{rangeLabel}</div>
146
+ <Button type="button" variant="outline" size="sm" onClick={() => shiftRange('next')} aria-label={t('schedule.range.next', 'Next')}>
173
147
  <ChevronRight className="size-4" aria-hidden />
174
- </IconButton>
148
+ </Button>
175
149
  </div>
176
- <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 md:ml-auto">
177
- <DateRangePicker
178
- value={range}
179
- onChange={(next) => {
180
- if (!next) return
181
- onRangeChange({ start: startOfDay(next.start), end: endOfDay(next.end) })
182
- }}
183
- size="sm"
184
- showPresets={false}
185
- numberOfMonths={2}
186
- aria-label={t('schedule.range.label', 'Date range')}
187
- />
188
- {onTimezoneChange ? (
189
- <Select value={timezone ?? undefined} onValueChange={onTimezoneChange}>
190
- <SelectTrigger
191
- size="sm"
192
- className="w-auto min-w-[10rem]"
193
- aria-label={t('schedule.range.timezone', 'Timezone')}
194
- >
195
- <SelectValue placeholder={t('schedule.range.timezone.placeholder', 'UTC')} />
196
- </SelectTrigger>
197
- <SelectContent>
198
- {timezoneOptions.map((option) => (
199
- <SelectItem key={option} value={option}>
200
- {option}
201
- </SelectItem>
202
- ))}
203
- </SelectContent>
204
- </Select>
205
- ) : null}
150
+ <div className="flex flex-wrap items-center gap-3">
151
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
152
+ <span>{t('schedule.range.start', 'Start')}</span>
153
+ <Input
154
+ type="date"
155
+ value={formatDateInputValue(range.start)}
156
+ onChange={(event) => {
157
+ const nextStart = parseDateInputValue(event.target.value, range.start)
158
+ onRangeChange({ start: nextStart, end: range.end })
159
+ }}
160
+ className="h-8 w-full sm:w-[140px]"
161
+ />
162
+ </label>
163
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
164
+ <span>{t('schedule.range.end', 'End')}</span>
165
+ <Input
166
+ type="date"
167
+ value={formatDateInputValue(range.end)}
168
+ onChange={(event) => {
169
+ const nextEnd = parseDateInputValue(event.target.value, range.end)
170
+ onRangeChange({ start: range.start, end: nextEnd })
171
+ }}
172
+ className="h-8 w-full sm:w-[140px]"
173
+ />
174
+ </label>
175
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
176
+ <span>{t('schedule.range.timezone', 'Timezone')}</span>
177
+ <Input
178
+ type="text"
179
+ value={timezone ?? ''}
180
+ onChange={(event) => onTimezoneChange?.(event.target.value)}
181
+ className="h-8 w-full sm:w-[180px]"
182
+ placeholder={t('schedule.range.timezone.placeholder', 'UTC')}
183
+ />
184
+ </label>
206
185
  </div>
207
186
  </div>
208
187
  )
@@ -14,7 +14,6 @@ import { Switch } from '../../primitives/switch'
14
14
  import { Card, CardContent, CardHeader, CardTitle } from '../../primitives/card'
15
15
  import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../primitives/dialog'
16
16
  import { Tag } from '../../primitives/tag'
17
- import { Badge } from '../../primitives/badge'
18
17
  import {
19
18
  Select,
20
19
  SelectContent,
@@ -32,14 +31,12 @@ import { useConfirmDialog } from '../confirm-dialog'
32
31
  import { useGuardedMutation } from '../injection/useGuardedMutation'
33
32
  import {
34
33
  applyCustomizationDraft,
35
- applyGroupHidden,
36
34
  applyItemOrder,
37
35
  cloneSidebarGroups,
38
36
  collectSidebarDefaults,
39
37
  filterMainSidebarGroups,
40
38
  mergeGroupOrder,
41
39
  resolveGroupKey,
42
- resolveGroupVisibility,
43
40
  resolveItemKey,
44
41
  type SidebarCustomizationDraft,
45
42
  type SidebarGroup,
@@ -551,19 +548,6 @@ export function SidebarCustomizationEditor({
551
548
  })
552
549
  }, [updateDraft])
553
550
 
554
- /**
555
- * Hiding a whole group previously meant toggling every item in it one at a time — a dozen-plus
556
- * clicks for what reads as a single decision. Writes the same `hiddenItemIds` keys the per-item
557
- * switches do, so the persisted settings are indistinguishable from reaching this state by hand.
558
- */
559
- const setGroupHidden = React.useCallback((groupKey: string, hidden: boolean) => {
560
- updateDraft((draft) => {
561
- const group = baseSnapshotRef.current?.find((candidate) => resolveGroupKey(candidate) === groupKey)
562
- if (!group) return draft
563
- return { ...draft, hiddenItemIds: applyGroupHidden(draft.hiddenItemIds, group, hidden) }
564
- })
565
- }, [updateDraft])
566
-
567
551
  const reset = React.useCallback(() => {
568
552
  if (!baseSnapshotRef.current) return
569
553
  if (selectedVariant) {
@@ -1238,30 +1222,7 @@ export function SidebarCustomizationEditor({
1238
1222
  </p>
1239
1223
  ) : null}
1240
1224
  </div>
1241
- <div className="flex shrink-0 items-center gap-2 mt-6">
1242
- {(() => {
1243
- const visibility = resolveGroupVisibility(baseGroup, draft.hiddenItemIds)
1244
- const groupName = trimmedValue.length > 0 ? trimmedValue : placeholder
1245
- const label = visibility === 'partial'
1246
- ? t('appShell.sidebarCustomizationHideGroupPartial', 'Some items in {group} are hidden — turn off to hide the whole group', { group: groupName })
1247
- : t('appShell.sidebarCustomizationShowGroup', 'Show {group}', { group: groupName })
1248
- return (
1249
- <>
1250
- {visibility === 'hidden' ? (
1251
- <Badge variant="muted" size="sm" className="uppercase tracking-wide">
1252
- {t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
1253
- </Badge>
1254
- ) : null}
1255
- <Switch
1256
- checked={visibility !== 'hidden'}
1257
- onCheckedChange={(next) => setGroupHidden(groupId, next !== true)}
1258
- disabled={isBusy}
1259
- aria-label={label}
1260
- title={label}
1261
- />
1262
- </>
1263
- )
1264
- })()}
1225
+ <div className="flex shrink-0 items-center gap-1 mt-[26px]">
1265
1226
  <IconButton
1266
1227
  type="button"
1267
1228
  variant="outline"
@@ -1388,9 +1349,9 @@ function ItemRow({ item, draft, saving, onLabelChange, onHiddenChange, t, depth,
1388
1349
  </div>
1389
1350
  <div className="flex shrink-0 items-center gap-2 pt-1.5">
1390
1351
  {hidden ? (
1391
- <Badge variant="muted" size="sm" className="uppercase tracking-wide">
1352
+ <span className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1392
1353
  {t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
1393
- </Badge>
1354
+ </span>
1394
1355
  ) : null}
1395
1356
  <Switch
1396
1357
  checked={!hidden}
@@ -156,53 +156,6 @@ export function applyCustomizationDraft(
156
156
  return result
157
157
  }
158
158
 
159
- /** Every item key a group owns, including nested children. */
160
- export function collectGroupItemKeys(group: SidebarGroup): string[] {
161
- const keys: string[] = []
162
- const walk = (items: SidebarItem[]) => {
163
- for (const item of items) {
164
- keys.push(resolveItemKey(item))
165
- if (item.children?.length) walk(item.children)
166
- }
167
- }
168
- walk(group.items)
169
- return keys
170
- }
171
-
172
- /**
173
- * Whether a group is fully visible, fully hidden, or in between. `AppShell` drops a group once every
174
- * one of its items is hidden, so `'hidden'` is the state that removes the group from the sidebar.
175
- */
176
- export function resolveGroupVisibility(
177
- group: SidebarGroup,
178
- hiddenItemIds: Record<string, boolean>,
179
- ): 'visible' | 'hidden' | 'partial' {
180
- const keys = collectGroupItemKeys(group)
181
- if (keys.length === 0) return 'visible'
182
- const hiddenCount = keys.filter((key) => hiddenItemIds[key]).length
183
- if (hiddenCount === 0) return 'visible'
184
- if (hiddenCount === keys.length) return 'hidden'
185
- return 'partial'
186
- }
187
-
188
- /**
189
- * Hides or shows every item in a group at once. Writes the same `hiddenItemIds` keys the per-item
190
- * switches use, so the resulting settings are indistinguishable from reaching this state by toggling
191
- * each item by hand — no new persisted shape, no migration.
192
- */
193
- export function applyGroupHidden(
194
- hiddenItemIds: Record<string, boolean>,
195
- group: SidebarGroup,
196
- hidden: boolean,
197
- ): Record<string, boolean> {
198
- const next = { ...hiddenItemIds }
199
- for (const key of collectGroupItemKeys(group)) {
200
- if (hidden) next[key] = true
201
- else delete next[key]
202
- }
203
- return next
204
- }
205
-
206
159
  /**
207
160
  * Filters groups to include only main sidebar items.
208
161
  * Excludes items with pageContext 'settings' or 'profile' from customization.
@@ -149,6 +149,33 @@ describe('apiFetch', () => {
149
149
  expect(flash).not.toHaveBeenCalled()
150
150
  })
151
151
 
152
+ it('stays silent when a login-page 401 lands after the post-login navigation', async () => {
153
+ window.history.pushState({}, '', '/login')
154
+ ;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () => {
155
+ // The sign-in completes while the pre-auth probe is still in flight, so the
156
+ // app has already client-navigated to /backend when the 401 arrives.
157
+ window.history.pushState({}, '', '/backend')
158
+ return createMockResponse(401, { error: 'Unauthorized' })
159
+ })
160
+
161
+ const result = await apiFetch('/api/auth/feature-check', { method: 'POST' })
162
+
163
+ expect(result.status).toBe(401)
164
+ expect(flash).not.toHaveBeenCalled()
165
+ })
166
+
167
+ it('stays silent when a 401 lands after navigating to the login page', async () => {
168
+ ;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () => {
169
+ window.history.pushState({}, '', '/login')
170
+ return createMockResponse(401, { error: 'Unauthorized' })
171
+ })
172
+
173
+ const result = await apiFetch('/api/private')
174
+
175
+ expect(result.status).toBe(401)
176
+ expect(flash).not.toHaveBeenCalled()
177
+ })
178
+
152
179
  it('throws UnauthorizedError for 401 responses by default', async () => {
153
180
  ;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () =>
154
181
  createMockResponse(401, { error: 'Unauthorized' }),
@@ -107,64 +107,3 @@ describe('readApiResultOrThrow', () => {
107
107
  expect(result).toBeNull()
108
108
  })
109
109
  })
110
-
111
- describe('aborted requests', () => {
112
- beforeEach(() => {
113
- jest.resetAllMocks()
114
- })
115
-
116
- function createAbortedBodyResponse(): Response {
117
- const abortError = new Error('The operation was aborted.')
118
- abortError.name = 'AbortError'
119
- const response = {
120
- ok: true,
121
- status: 200,
122
- headers: new Map<string, string>(),
123
- text: jest.fn(async () => { throw abortError }),
124
- clone: () => response,
125
- } as unknown as Response
126
- return response
127
- }
128
-
129
- it('rejects with AbortError instead of reporting an empty payload', async () => {
130
- ;(apiFetch as jest.Mock).mockResolvedValue(createAbortedBodyResponse())
131
- const controller = new AbortController()
132
- controller.abort()
133
-
134
- await expect(
135
- readApiResultOrThrow('/api/interactions', { signal: controller.signal }),
136
- ).rejects.toMatchObject({ name: 'AbortError' })
137
- })
138
-
139
- it('does not mask a genuinely empty payload when the request was not aborted', async () => {
140
- ;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('', { status: 200 }))
141
- const controller = new AbortController()
142
-
143
- await expect(
144
- readApiResultOrThrow('/api/interactions', { signal: controller.signal }),
145
- ).rejects.toThrow('Missing response payload (200)')
146
- })
147
-
148
- it('keeps the caller-provided fallback when one is configured', async () => {
149
- ;(apiFetch as jest.Mock).mockResolvedValue(createAbortedBodyResponse())
150
- const controller = new AbortController()
151
- controller.abort()
152
-
153
- const call = await apiCall<{ items: string[] }>(
154
- '/api/interactions',
155
- { signal: controller.signal },
156
- { fallback: { items: [] } },
157
- )
158
- expect(call.result).toEqual({ items: [] })
159
- })
160
-
161
- it('propagates AbortError raised by a custom parser', async () => {
162
- ;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('{}', { status: 200 }))
163
- const abortError = new Error('The operation was aborted.')
164
- abortError.name = 'AbortError'
165
-
166
- await expect(
167
- apiCall('/api/interactions', undefined, { parse: async () => { throw abortError } }),
168
- ).rejects.toMatchObject({ name: 'AbortError' })
169
- })
170
- })