@open-mercato/ui 0.4.2-canary-c02407ff85

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 (319) hide show
  1. package/build.mjs +62 -0
  2. package/dist/backend/AppShell.js +902 -0
  3. package/dist/backend/AppShell.js.map +7 -0
  4. package/dist/backend/ConfirmDialog.js +17 -0
  5. package/dist/backend/ConfirmDialog.js.map +7 -0
  6. package/dist/backend/ContextHelp.js +31 -0
  7. package/dist/backend/ContextHelp.js.map +7 -0
  8. package/dist/backend/CrudForm.js +2028 -0
  9. package/dist/backend/CrudForm.js.map +7 -0
  10. package/dist/backend/DataTable.js +1363 -0
  11. package/dist/backend/DataTable.js.map +7 -0
  12. package/dist/backend/EmptyState.js +52 -0
  13. package/dist/backend/EmptyState.js.map +7 -0
  14. package/dist/backend/FilterBar.js +140 -0
  15. package/dist/backend/FilterBar.js.map +7 -0
  16. package/dist/backend/FilterOverlay.js +279 -0
  17. package/dist/backend/FilterOverlay.js.map +7 -0
  18. package/dist/backend/FlashMessages.js +66 -0
  19. package/dist/backend/FlashMessages.js.map +7 -0
  20. package/dist/backend/JsonBuilder.js +322 -0
  21. package/dist/backend/JsonBuilder.js.map +7 -0
  22. package/dist/backend/JsonDisplay.js +203 -0
  23. package/dist/backend/JsonDisplay.js.map +7 -0
  24. package/dist/backend/Page.js +27 -0
  25. package/dist/backend/Page.js.map +7 -0
  26. package/dist/backend/PerspectiveSidebar.js +282 -0
  27. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  28. package/dist/backend/RowActions.js +148 -0
  29. package/dist/backend/RowActions.js.map +7 -0
  30. package/dist/backend/TruncatedCell.js +92 -0
  31. package/dist/backend/TruncatedCell.js.map +7 -0
  32. package/dist/backend/UserMenu.js +107 -0
  33. package/dist/backend/UserMenu.js.map +7 -0
  34. package/dist/backend/ValueIcons.js +34 -0
  35. package/dist/backend/ValueIcons.js.map +7 -0
  36. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
  37. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  38. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  39. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  40. package/dist/backend/dashboard/DashboardScreen.js +578 -0
  41. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  42. package/dist/backend/dashboard/index.js +5 -0
  43. package/dist/backend/dashboard/index.js.map +7 -0
  44. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  45. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  46. package/dist/backend/detail/ActivitiesSection.js +962 -0
  47. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  48. package/dist/backend/detail/AddressEditor.js +413 -0
  49. package/dist/backend/detail/AddressEditor.js.map +7 -0
  50. package/dist/backend/detail/AddressTiles.js +437 -0
  51. package/dist/backend/detail/AddressTiles.js.map +7 -0
  52. package/dist/backend/detail/AddressesSection.js +264 -0
  53. package/dist/backend/detail/AddressesSection.js.map +7 -0
  54. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  55. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  56. package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
  57. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  58. package/dist/backend/detail/AttachmentsSection.js +367 -0
  59. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  60. package/dist/backend/detail/CustomDataSection.js +433 -0
  61. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  62. package/dist/backend/detail/DetailFieldsSection.js +75 -0
  63. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  64. package/dist/backend/detail/ErrorMessage.js +28 -0
  65. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  66. package/dist/backend/detail/InlineEditors.js +681 -0
  67. package/dist/backend/detail/InlineEditors.js.map +7 -0
  68. package/dist/backend/detail/LoadingMessage.js +14 -0
  69. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  70. package/dist/backend/detail/NotesSection.js +1032 -0
  71. package/dist/backend/detail/NotesSection.js.map +7 -0
  72. package/dist/backend/detail/TabEmptyState.js +25 -0
  73. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  74. package/dist/backend/detail/TagsSection.js +254 -0
  75. package/dist/backend/detail/TagsSection.js.map +7 -0
  76. package/dist/backend/detail/addressFormat.js +77 -0
  77. package/dist/backend/detail/addressFormat.js.map +7 -0
  78. package/dist/backend/detail/index.js +34 -0
  79. package/dist/backend/detail/index.js.map +7 -0
  80. package/dist/backend/fields/registry.generated.js +8 -0
  81. package/dist/backend/fields/registry.generated.js.map +7 -0
  82. package/dist/backend/fields/registry.js +29 -0
  83. package/dist/backend/fields/registry.js.map +7 -0
  84. package/dist/backend/indexes/PartialIndexBanner.js +58 -0
  85. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  86. package/dist/backend/indexes/store.js +62 -0
  87. package/dist/backend/indexes/store.js.map +7 -0
  88. package/dist/backend/injection/InjectionSpot.js +179 -0
  89. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  90. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  91. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  92. package/dist/backend/injection/helpers.js +26 -0
  93. package/dist/backend/injection/helpers.js.map +7 -0
  94. package/dist/backend/injection/widgetRegistry.js +55 -0
  95. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  96. package/dist/backend/inputs/ComboboxInput.js +225 -0
  97. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  98. package/dist/backend/inputs/LookupSelect.js +191 -0
  99. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  100. package/dist/backend/inputs/PhoneNumberField.js +100 -0
  101. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  102. package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
  103. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  104. package/dist/backend/inputs/TagsInput.js +222 -0
  105. package/dist/backend/inputs/TagsInput.js.map +7 -0
  106. package/dist/backend/inputs/index.js +6 -0
  107. package/dist/backend/inputs/index.js.map +7 -0
  108. package/dist/backend/operations/LastOperationBanner.js +80 -0
  109. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  110. package/dist/backend/operations/store.js +183 -0
  111. package/dist/backend/operations/store.js.map +7 -0
  112. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  113. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  114. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  115. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  116. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  117. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  118. package/dist/backend/schedule/ScheduleView.js +165 -0
  119. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  120. package/dist/backend/schedule/index.js +6 -0
  121. package/dist/backend/schedule/index.js.map +7 -0
  122. package/dist/backend/schedule/recurrence.js +83 -0
  123. package/dist/backend/schedule/recurrence.js.map +7 -0
  124. package/dist/backend/schedule/types.js +1 -0
  125. package/dist/backend/schedule/types.js.map +7 -0
  126. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  127. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  128. package/dist/backend/utils/api.js +127 -0
  129. package/dist/backend/utils/api.js.map +7 -0
  130. package/dist/backend/utils/apiCall.js +48 -0
  131. package/dist/backend/utils/apiCall.js.map +7 -0
  132. package/dist/backend/utils/crud.js +126 -0
  133. package/dist/backend/utils/crud.js.map +7 -0
  134. package/dist/backend/utils/customFieldColumns.js +56 -0
  135. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  136. package/dist/backend/utils/customFieldDefs.js +143 -0
  137. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  138. package/dist/backend/utils/customFieldFilters.js +126 -0
  139. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  140. package/dist/backend/utils/customFieldForms.js +162 -0
  141. package/dist/backend/utils/customFieldForms.js.map +7 -0
  142. package/dist/backend/utils/customFieldValues.js +26 -0
  143. package/dist/backend/utils/customFieldValues.js.map +7 -0
  144. package/dist/backend/utils/flash.js +16 -0
  145. package/dist/backend/utils/flash.js.map +7 -0
  146. package/dist/backend/utils/nav.js +185 -0
  147. package/dist/backend/utils/nav.js.map +7 -0
  148. package/dist/backend/utils/serverErrors.js +230 -0
  149. package/dist/backend/utils/serverErrors.js.map +7 -0
  150. package/dist/frontend/AuthFooter.js +23 -0
  151. package/dist/frontend/AuthFooter.js.map +7 -0
  152. package/dist/frontend/LanguageSwitcher.js +57 -0
  153. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  154. package/dist/frontend/Layout.js +14 -0
  155. package/dist/frontend/Layout.js.map +7 -0
  156. package/dist/index.js +32 -0
  157. package/dist/index.js.map +7 -0
  158. package/dist/primitives/DataLoader.js +67 -0
  159. package/dist/primitives/DataLoader.js.map +7 -0
  160. package/dist/primitives/ErrorNotice.js +20 -0
  161. package/dist/primitives/ErrorNotice.js.map +7 -0
  162. package/dist/primitives/alert.js +38 -0
  163. package/dist/primitives/alert.js.map +7 -0
  164. package/dist/primitives/badge.js +28 -0
  165. package/dist/primitives/badge.js.map +7 -0
  166. package/dist/primitives/button.js +44 -0
  167. package/dist/primitives/button.js.map +7 -0
  168. package/dist/primitives/card.js +91 -0
  169. package/dist/primitives/card.js.map +7 -0
  170. package/dist/primitives/checkbox.js +28 -0
  171. package/dist/primitives/checkbox.js.map +7 -0
  172. package/dist/primitives/dialog.js +90 -0
  173. package/dist/primitives/dialog.js.map +7 -0
  174. package/dist/primitives/input.js +22 -0
  175. package/dist/primitives/input.js.map +7 -0
  176. package/dist/primitives/label.js +21 -0
  177. package/dist/primitives/label.js.map +7 -0
  178. package/dist/primitives/separator.js +9 -0
  179. package/dist/primitives/separator.js.map +7 -0
  180. package/dist/primitives/spinner.js +24 -0
  181. package/dist/primitives/spinner.js.map +7 -0
  182. package/dist/primitives/switch.js +80 -0
  183. package/dist/primitives/switch.js.map +7 -0
  184. package/dist/primitives/table.js +29 -0
  185. package/dist/primitives/table.js.map +7 -0
  186. package/dist/primitives/tabs.js +87 -0
  187. package/dist/primitives/tabs.js.map +7 -0
  188. package/dist/primitives/textarea.js +21 -0
  189. package/dist/primitives/textarea.js.map +7 -0
  190. package/dist/primitives/tooltip.js +60 -0
  191. package/dist/primitives/tooltip.js.map +7 -0
  192. package/dist/theme/QueryProvider.js +44 -0
  193. package/dist/theme/QueryProvider.js.map +7 -0
  194. package/dist/theme/ThemeProvider.js +95 -0
  195. package/dist/theme/ThemeProvider.js.map +7 -0
  196. package/dist/theme/ThemeToggle.js +88 -0
  197. package/dist/theme/ThemeToggle.js.map +7 -0
  198. package/dist/theme/index.js +10 -0
  199. package/dist/theme/index.js.map +7 -0
  200. package/dist/types/react-big-calendar.d.js +1 -0
  201. package/dist/types/react-big-calendar.d.js.map +7 -0
  202. package/jest.config.cjs +23 -0
  203. package/jest.setup.ts +55 -0
  204. package/package.json +105 -0
  205. package/src/backend/AppShell.tsx +1096 -0
  206. package/src/backend/ConfirmDialog.tsx +19 -0
  207. package/src/backend/ContextHelp.tsx +38 -0
  208. package/src/backend/CrudForm.tsx +2503 -0
  209. package/src/backend/DataTable.tsx +1730 -0
  210. package/src/backend/EmptyState.tsx +65 -0
  211. package/src/backend/FilterBar.tsx +161 -0
  212. package/src/backend/FilterOverlay.tsx +328 -0
  213. package/src/backend/FlashMessages.tsx +82 -0
  214. package/src/backend/JsonBuilder.tsx +362 -0
  215. package/src/backend/JsonDisplay.tsx +254 -0
  216. package/src/backend/Page.tsx +30 -0
  217. package/src/backend/PerspectiveSidebar.tsx +337 -0
  218. package/src/backend/RowActions.tsx +151 -0
  219. package/src/backend/TruncatedCell.tsx +133 -0
  220. package/src/backend/UserMenu.tsx +118 -0
  221. package/src/backend/ValueIcons.tsx +48 -0
  222. package/src/backend/__tests__/AppShell.test.tsx +115 -0
  223. package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
  224. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  225. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  226. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  227. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  228. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
  229. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  230. package/src/backend/dashboard/DashboardScreen.tsx +684 -0
  231. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
  232. package/src/backend/dashboard/index.ts +1 -0
  233. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  234. package/src/backend/detail/ActivitiesSection.tsx +1284 -0
  235. package/src/backend/detail/AddressEditor.tsx +472 -0
  236. package/src/backend/detail/AddressTiles.tsx +587 -0
  237. package/src/backend/detail/AddressesSection.tsx +346 -0
  238. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  239. package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
  240. package/src/backend/detail/AttachmentsSection.tsx +414 -0
  241. package/src/backend/detail/CustomDataSection.tsx +530 -0
  242. package/src/backend/detail/DetailFieldsSection.tsx +147 -0
  243. package/src/backend/detail/ErrorMessage.tsx +32 -0
  244. package/src/backend/detail/InlineEditors.tsx +877 -0
  245. package/src/backend/detail/LoadingMessage.tsx +14 -0
  246. package/src/backend/detail/NotesSection.tsx +1275 -0
  247. package/src/backend/detail/TabEmptyState.tsx +48 -0
  248. package/src/backend/detail/TagsSection.tsx +314 -0
  249. package/src/backend/detail/addressFormat.tsx +121 -0
  250. package/src/backend/detail/index.ts +44 -0
  251. package/src/backend/fields/registry.generated.ts +8 -0
  252. package/src/backend/fields/registry.ts +38 -0
  253. package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
  254. package/src/backend/indexes/store.ts +88 -0
  255. package/src/backend/injection/InjectionSpot.tsx +236 -0
  256. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  257. package/src/backend/injection/helpers.ts +35 -0
  258. package/src/backend/injection/widgetRegistry.ts +68 -0
  259. package/src/backend/inputs/ComboboxInput.tsx +269 -0
  260. package/src/backend/inputs/LookupSelect.tsx +247 -0
  261. package/src/backend/inputs/PhoneNumberField.tsx +129 -0
  262. package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
  263. package/src/backend/inputs/TagsInput.tsx +259 -0
  264. package/src/backend/inputs/index.ts +5 -0
  265. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  266. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
  267. package/src/backend/operations/store.ts +230 -0
  268. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  269. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  270. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  271. package/src/backend/schedule/ScheduleView.tsx +198 -0
  272. package/src/backend/schedule/index.ts +5 -0
  273. package/src/backend/schedule/recurrence.ts +99 -0
  274. package/src/backend/schedule/types.ts +26 -0
  275. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  276. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  277. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  278. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  279. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  280. package/src/backend/utils/api.ts +149 -0
  281. package/src/backend/utils/apiCall.ts +96 -0
  282. package/src/backend/utils/crud.ts +174 -0
  283. package/src/backend/utils/customFieldColumns.ts +71 -0
  284. package/src/backend/utils/customFieldDefs.ts +245 -0
  285. package/src/backend/utils/customFieldFilters.ts +145 -0
  286. package/src/backend/utils/customFieldForms.ts +196 -0
  287. package/src/backend/utils/customFieldValues.ts +41 -0
  288. package/src/backend/utils/flash.ts +17 -0
  289. package/src/backend/utils/nav.ts +238 -0
  290. package/src/backend/utils/serverErrors.ts +302 -0
  291. package/src/frontend/AuthFooter.tsx +29 -0
  292. package/src/frontend/LanguageSwitcher.tsx +66 -0
  293. package/src/frontend/Layout.tsx +13 -0
  294. package/src/index.ts +32 -0
  295. package/src/primitives/DataLoader.tsx +92 -0
  296. package/src/primitives/ErrorNotice.tsx +26 -0
  297. package/src/primitives/alert.tsx +52 -0
  298. package/src/primitives/badge.tsx +31 -0
  299. package/src/primitives/button.tsx +47 -0
  300. package/src/primitives/card.tsx +92 -0
  301. package/src/primitives/checkbox.tsx +28 -0
  302. package/src/primitives/dialog.tsx +110 -0
  303. package/src/primitives/input.tsx +20 -0
  304. package/src/primitives/label.tsx +18 -0
  305. package/src/primitives/separator.tsx +7 -0
  306. package/src/primitives/spinner.tsx +27 -0
  307. package/src/primitives/switch.tsx +86 -0
  308. package/src/primitives/table.tsx +27 -0
  309. package/src/primitives/tabs.tsx +128 -0
  310. package/src/primitives/textarea.tsx +20 -0
  311. package/src/primitives/tooltip.tsx +85 -0
  312. package/src/theme/QueryProvider.tsx +46 -0
  313. package/src/theme/ThemeProvider.tsx +120 -0
  314. package/src/theme/ThemeToggle.tsx +88 -0
  315. package/src/theme/index.ts +3 -0
  316. package/src/types/react-big-calendar.d.ts +16 -0
  317. package/tsconfig.build.json +11 -0
  318. package/tsconfig.json +9 -0
  319. package/watch.mjs +6 -0
@@ -0,0 +1,65 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '@open-mercato/shared/lib/utils'
5
+ import { Button } from '@open-mercato/ui/primitives/button'
6
+ import { Plus } from 'lucide-react'
7
+
8
+ type EmptyStateAction = {
9
+ label: string
10
+ onClick?: () => void
11
+ icon?: React.ReactNode
12
+ disabled?: boolean
13
+ }
14
+
15
+ type EmptyStateProps = {
16
+ title: string
17
+ description?: string
18
+ action?: EmptyStateAction
19
+ actionLabel?: string
20
+ onAction?: () => void
21
+ icon?: React.ReactNode
22
+ className?: string
23
+ actionLabelClassName?: string
24
+ children?: React.ReactNode
25
+ }
26
+
27
+ export function EmptyState({
28
+ title,
29
+ description,
30
+ action,
31
+ actionLabel,
32
+ onAction,
33
+ icon,
34
+ className,
35
+ actionLabelClassName,
36
+ children,
37
+ }: EmptyStateProps) {
38
+ const resolvedAction = action ?? (actionLabel ? { label: actionLabel, onClick: onAction } : undefined)
39
+ return (
40
+ <div
41
+ className={cn(
42
+ 'flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/40 bg-muted/30 px-6 py-10 text-center',
43
+ className
44
+ )}
45
+ >
46
+ {icon ? <div className="mb-3 text-muted-foreground">{icon}</div> : null}
47
+ <p className="text-sm font-medium text-foreground">{title}</p>
48
+ {description ? <p className="mt-2 text-sm text-muted-foreground">{description}</p> : null}
49
+ {children}
50
+ {resolvedAction ? (
51
+ <Button
52
+ type="button"
53
+ variant="outline"
54
+ size="sm"
55
+ onClick={resolvedAction.onClick}
56
+ className={cn('mt-4 inline-flex items-center gap-2 text-foreground', actionLabelClassName)}
57
+ disabled={resolvedAction.disabled}
58
+ >
59
+ {(resolvedAction.icon ?? <Plus className="h-4 w-4" aria-hidden />)}
60
+ <span>{resolvedAction.label}</span>
61
+ </Button>
62
+ ) : null}
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,161 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Button } from '../primitives/button'
4
+ import { FilterDef, FilterOverlay, FilterValues } from './FilterOverlay'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+
7
+ export type FilterBarProps = {
8
+ searchValue?: string
9
+ onSearchChange?: (v: string) => void
10
+ searchPlaceholder?: string
11
+ searchAlign?: 'left' | 'right'
12
+ filters?: FilterDef[]
13
+ values?: FilterValues
14
+ onApply?: (values: FilterValues) => void
15
+ onClear?: () => void
16
+ className?: string
17
+ leadingItems?: React.ReactNode
18
+ layout?: 'stacked' | 'inline'
19
+ filtersExtraContent?: React.ReactNode
20
+ }
21
+
22
+ export function FilterBar({
23
+ searchValue,
24
+ onSearchChange,
25
+ searchPlaceholder,
26
+ searchAlign = 'left',
27
+ filters = [],
28
+ values = {},
29
+ onApply,
30
+ onClear,
31
+ className,
32
+ leadingItems,
33
+ layout = 'stacked',
34
+ filtersExtraContent,
35
+ }: FilterBarProps) {
36
+ const t = useT()
37
+ const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.filterBar.searchPlaceholder', 'Search')
38
+ const [open, setOpen] = React.useState(false)
39
+ const [searchDraft, setSearchDraft] = React.useState(searchValue ?? '')
40
+ const lastAppliedSearchRef = React.useRef(searchValue ?? '')
41
+
42
+ React.useEffect(() => {
43
+ const next = searchValue ?? ''
44
+ lastAppliedSearchRef.current = next
45
+ setSearchDraft((prev) => (prev === next ? prev : next))
46
+ }, [searchValue])
47
+
48
+ React.useEffect(() => {
49
+ if (!onSearchChange) return
50
+ const handle = window.setTimeout(() => {
51
+ if (lastAppliedSearchRef.current === searchDraft) return
52
+ lastAppliedSearchRef.current = searchDraft
53
+ onSearchChange(searchDraft)
54
+ }, 1000)
55
+ return () => {
56
+ window.clearTimeout(handle)
57
+ }
58
+ }, [searchDraft, onSearchChange])
59
+
60
+ const activeCount = React.useMemo(() => {
61
+ const isActive = (v: any) => {
62
+ if (v == null) return false
63
+ if (typeof v === 'string') return v.trim() !== ''
64
+ if (Array.isArray(v)) return v.length > 0
65
+ if (typeof v === 'object') return Object.values(v).some((x) => x != null && x !== '')
66
+ return Boolean(v)
67
+ }
68
+ return Object.values(values).filter(isActive).length
69
+ }, [values])
70
+
71
+ const containerClass = `flex flex-col ${layout === 'inline' ? 'gap-1 sm:gap-2' : 'gap-2'} w-full`
72
+
73
+ return (
74
+ <div className={`${containerClass} ${className ?? ''}`}>
75
+ <div className="flex flex-wrap items-center gap-2 w-full">
76
+ {filters.length > 0 && (
77
+ <Button variant="outline" className="h-9" onClick={() => setOpen(true)}>
78
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" className="opacity-80"><path d="M3 4h18"/><path d="M6 8h12l-3 8H9L6 8z"/></svg>
79
+ {activeCount
80
+ ? t('ui.filterBar.filtersWithCount', 'Filters {count}', { count: activeCount })
81
+ : t('ui.filterBar.filters', 'Filters')
82
+ }
83
+ </Button>
84
+ )}
85
+ {leadingItems}
86
+ {onSearchChange && (
87
+ <div className={`relative w-full sm:w-[240px] ${searchAlign === 'right' ? 'ml-auto' : ''}`}>
88
+ <input
89
+ value={searchDraft}
90
+ onChange={(e) => setSearchDraft(e.target.value)}
91
+ placeholder={resolvedSearchPlaceholder}
92
+ className="h-9 w-full rounded border pl-8 pr-2 text-sm"
93
+ suppressHydrationWarning
94
+ />
95
+ <span className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground">🔍</span>
96
+ </div>
97
+ )}
98
+ </div>
99
+ {/* Active filter chips */}
100
+ {filters.length > 0 && activeCount > 0 && (
101
+ <div className="flex flex-wrap items-center gap-1">
102
+ {filters.map((f) => {
103
+ const v = (values as any)[f.id]
104
+ if (v == null || v === '' || (Array.isArray(v) && v.length === 0)) return null
105
+ const toLabel = (val: any) => {
106
+ if (typeof f.formatValue === 'function' && (typeof val === 'string' || typeof val === 'number')) {
107
+ const formatted = f.formatValue(String(val))
108
+ if (formatted) return formatted
109
+ }
110
+ if (f.type === 'select' && f.options) {
111
+ const o = f.options.find((o) => o.value === val)
112
+ return o ? o.label : String(val)
113
+ }
114
+ if (typeof val === 'object' && val.from == null && val.to == null) return null
115
+ if (typeof val === 'object') {
116
+ const from = val.from ?? ''
117
+ const to = val.to ? ` → ${val.to}` : ''
118
+ return `${from}${to}`.trim()
119
+ }
120
+ if (val === true) return t('common.yes', 'Yes')
121
+ if (val === false) return t('common.no', 'No')
122
+ return String(val)
123
+ }
124
+ const removeValue = (val?: any) => {
125
+ const next = { ...(values || {}) }
126
+ if (Array.isArray(v) && val !== undefined) next[f.id] = v.filter((x: any) => x !== val)
127
+ else delete (next as any)[f.id]
128
+ onApply?.(next)
129
+ }
130
+ if (Array.isArray(v)) {
131
+ return v.map((item) => (
132
+ <Button key={`${f.id}:${item}`} size="sm" variant="outline" onClick={() => removeValue(item)}>
133
+ {f.label}: {toLabel(item)} ×
134
+ </Button>
135
+ ))
136
+ }
137
+ const label = toLabel(v)
138
+ if (!label) return null
139
+ return (
140
+ <Button key={f.id} size="sm" variant="outline" onClick={() => removeValue()}>
141
+ {f.label}: {label} ×
142
+ </Button>
143
+ )
144
+ })}
145
+ </div>
146
+ )}
147
+ <FilterOverlay
148
+ title={t('ui.filterOverlay.title', 'Filters')}
149
+ filters={filters}
150
+ initialValues={values}
151
+ open={open}
152
+ onOpenChange={setOpen}
153
+ onApply={(v) => onApply?.(v)}
154
+ onClear={onClear}
155
+ extraContent={filtersExtraContent}
156
+ />
157
+ </div>
158
+ )
159
+ }
160
+
161
+ export type { FilterDef, FilterValues } from './FilterOverlay'
@@ -0,0 +1,328 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Button } from '../primitives/button'
4
+ import { TagsInput, type TagsInputOption } from './inputs/TagsInput'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+
7
+ export type FilterOption = { value: string; label: string; description?: string | null }
8
+
9
+ export type FilterDef = {
10
+ id: string
11
+ label: string
12
+ type: 'text' | 'select' | 'checkbox' | 'dateRange' | 'tags'
13
+ options?: FilterOption[]
14
+ // Optional async loader for options (used by select/tags)
15
+ loadOptions?: (query?: string) => Promise<FilterOption[]>
16
+ multiple?: boolean
17
+ placeholder?: string
18
+ group?: string
19
+ formatValue?: (value: string) => string
20
+ formatDescription?: (value: string) => string | null | undefined
21
+ }
22
+
23
+ export type FilterValues = Record<string, any>
24
+
25
+ export type FilterOverlayProps = {
26
+ title?: string
27
+ filters: FilterDef[]
28
+ initialValues: FilterValues
29
+ open: boolean
30
+ onOpenChange: (open: boolean) => void
31
+ onApply: (values: FilterValues) => void
32
+ onClear?: () => void
33
+ extraContent?: React.ReactNode
34
+ }
35
+
36
+ const EMPTY_FILTER_VALUES: FilterValues = {}
37
+
38
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
39
+ return value != null && typeof value === 'object' && !Array.isArray(value)
40
+ }
41
+
42
+ function normalizeKeys(source: FilterValues | null | undefined): string[] {
43
+ if (!source) return []
44
+ return Object.keys(source).filter((key) => source[key] !== undefined)
45
+ }
46
+
47
+ function areFieldValuesEqual(a: any, b: any): boolean {
48
+ if (a === b) return true
49
+ if (Array.isArray(a) && Array.isArray(b)) {
50
+ if (a.length !== b.length) return false
51
+ for (let i = 0; i < a.length; i += 1) {
52
+ if (!areFieldValuesEqual(a[i], b[i])) return false
53
+ }
54
+ return true
55
+ }
56
+ if (isPlainObject(a) && isPlainObject(b)) {
57
+ const keysA = normalizeKeys(a as FilterValues)
58
+ const keysB = normalizeKeys(b as FilterValues)
59
+ if (keysA.length !== keysB.length) return false
60
+ for (const key of keysA) {
61
+ if (!keysB.includes(key)) return false
62
+ if (!areFieldValuesEqual((a as FilterValues)[key], (b as FilterValues)[key])) return false
63
+ }
64
+ return true
65
+ }
66
+ return false
67
+ }
68
+
69
+ function areFilterValuesEqual(a?: FilterValues | null, b?: FilterValues | null): boolean {
70
+ if (a === b) return true
71
+ const keysA = normalizeKeys(a || EMPTY_FILTER_VALUES)
72
+ const keysB = normalizeKeys(b || EMPTY_FILTER_VALUES)
73
+ if (keysA.length !== keysB.length) return false
74
+ for (const key of keysA) {
75
+ if (!keysB.includes(key)) return false
76
+ if (!areFieldValuesEqual(a?.[key], b?.[key])) return false
77
+ }
78
+ return true
79
+ }
80
+
81
+ export function FilterOverlay({
82
+ title,
83
+ filters,
84
+ initialValues,
85
+ open,
86
+ onOpenChange,
87
+ onApply,
88
+ onClear,
89
+ extraContent,
90
+ }: FilterOverlayProps) {
91
+ const t = useT()
92
+ const defaultTitle = title ?? t('ui.filters.title', 'Filters')
93
+ const [values, setValues] = React.useState<FilterValues>(initialValues)
94
+ React.useEffect(() => {
95
+ setValues((prev) => (areFilterValuesEqual(prev, initialValues) ? prev : initialValues))
96
+ }, [initialValues])
97
+ const filtersSignature = React.useMemo(
98
+ () => filters.map((f) => `${f.id}:${f.type}:${Boolean((f as any).loadOptions)}:${(f.options || []).length}`).join('|'),
99
+ [filters]
100
+ )
101
+ const lastLoadedSignatureRef = React.useRef<string | null>(null)
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ const stableFilters = React.useMemo(() => filters, [filtersSignature])
104
+
105
+ // Load dynamic options for filters that request it
106
+ const [dynamicOptions, setDynamicOptions] = React.useState<Record<string, FilterOption[]>>({})
107
+ React.useEffect(() => {
108
+ if (!open) return
109
+ if (lastLoadedSignatureRef.current === filtersSignature) return
110
+ lastLoadedSignatureRef.current = filtersSignature
111
+ setDynamicOptions({})
112
+ let cancelled = false
113
+ const loadAll = async () => {
114
+ const loaders = filters
115
+ .filter((f): f is FilterDef & { loadOptions: (query?: string) => Promise<FilterOption[]> } => (f as any).loadOptions != null)
116
+ .map(async (f) => {
117
+ try {
118
+ const opts = await (f as any).loadOptions()
119
+ if (!cancelled) setDynamicOptions((prev) => ({ ...prev, [f.id]: opts }))
120
+ } catch {
121
+ // ignore
122
+ }
123
+ })
124
+ await Promise.all(loaders)
125
+ }
126
+ loadAll()
127
+ return () => {
128
+ cancelled = true
129
+ }
130
+ }, [filters, filtersSignature, open])
131
+ React.useEffect(() => {
132
+ if (!open) {
133
+ lastLoadedSignatureRef.current = null
134
+ }
135
+ }, [open])
136
+
137
+ const setValue = (id: string, v: any) => setValues((prev) => ({ ...prev, [id]: v }))
138
+
139
+ const handleApply = () => {
140
+ onApply(values)
141
+ onOpenChange(false)
142
+ }
143
+
144
+ const handleClear = () => {
145
+ setValues({})
146
+ onClear?.()
147
+ }
148
+
149
+ const tagLoaders = React.useMemo(() => {
150
+ const map = new Map<string, (q?: string) => Promise<Array<string | TagsInputOption>>>()
151
+ for (const f of stableFilters) {
152
+ if (f.type === 'tags' && typeof f.loadOptions === 'function') {
153
+ const fieldId = f.id
154
+ const load = f.loadOptions as (query?: string) => Promise<FilterOption[]>
155
+ map.set(fieldId, async (q?: string) => {
156
+ const query = (q ?? '').trim()
157
+ if (!query.length) return []
158
+ try {
159
+ const opts = await load(query)
160
+ setDynamicOptions((prev) => ({ ...prev, [fieldId]: opts }))
161
+ return opts.map((o) => ({ value: o.value, label: o.label, description: o.description ?? null }))
162
+ } catch {
163
+ return []
164
+ }
165
+ })
166
+ }
167
+ }
168
+ return map
169
+ }, [stableFilters])
170
+
171
+ return (
172
+ <>
173
+ {open && (
174
+ <div className="fixed inset-0 z-50">
175
+ <div className="absolute inset-0 bg-black/30" onClick={() => onOpenChange(false)} />
176
+ <div className="absolute left-0 top-0 h-full w-full sm:w-[380px] bg-background shadow-xl border-r flex flex-col">
177
+ <div className="flex items-center justify-between p-4 border-b">
178
+ <h2 className="text-base font-semibold">{defaultTitle}</h2>
179
+ <button className="text-sm text-muted-foreground" onClick={() => onOpenChange(false)}>{t('common.close')}</button>
180
+ </div>
181
+ {/* Top actions: duplicate Clear/Apply */}
182
+ <div className="px-4 py-2 border-b flex items-center justify-between gap-2">
183
+ <Button variant="outline" size="sm" onClick={handleClear}>{t('ui.filters.actions.clear', 'Clear')}</Button>
184
+ <Button size="sm" onClick={handleApply} className="inline-flex items-center gap-2">
185
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" className="opacity-80"><path d="M3 4h18"/><path d="M6 8h12l-3 8H9L6 8z"/></svg>
186
+ {t('ui.filters.actions.apply', 'Apply')}
187
+ </Button>
188
+ </div>
189
+ <div className="flex-1 overflow-auto p-4 space-y-4">
190
+ {extraContent ? <div className="space-y-2 rounded-md border bg-muted/30 p-3">{extraContent}</div> : null}
191
+ {filters.map((f) => (
192
+ <div key={f.id} className="space-y-2">
193
+ <div className="text-sm font-medium">{f.label}</div>
194
+ {f.type === 'text' && (
195
+ <input
196
+ type="text"
197
+ className="w-full h-9 rounded border px-2 text-sm"
198
+ placeholder={f.placeholder}
199
+ value={values[f.id] ?? ''}
200
+ onChange={(e) => setValue(f.id, e.target.value || undefined)}
201
+ />
202
+ )}
203
+ {f.type === 'dateRange' && (
204
+ <div className="grid grid-cols-1 gap-2">
205
+ <div>
206
+ <div className="text-xs text-muted-foreground mb-1">{t('ui.filters.dateRange.from', 'From')}</div>
207
+ <input
208
+ type="date"
209
+ className="w-full h-9 rounded border px-2 text-sm"
210
+ value={values[f.id]?.from ?? ''}
211
+ onChange={(e) => setValue(f.id, { ...(values[f.id] ?? {}), from: e.target.value || undefined })}
212
+ />
213
+ </div>
214
+ <div>
215
+ <div className="text-xs text-muted-foreground mb-1">{t('ui.filters.dateRange.to', 'To')}</div>
216
+ <input
217
+ type="date"
218
+ className="w-full h-9 rounded border px-2 text-sm"
219
+ value={values[f.id]?.to ?? ''}
220
+ onChange={(e) => setValue(f.id, { ...(values[f.id] ?? {}), to: e.target.value || undefined })}
221
+ />
222
+ </div>
223
+ </div>
224
+ )}
225
+ {f.type === 'select' && (
226
+ <div className="space-y-1">
227
+ {f.multiple ? (
228
+ <div className="flex flex-col gap-1">
229
+ {(f.options || dynamicOptions[f.id] || []).map((opt) => {
230
+ const arr: string[] = Array.isArray(values[f.id]) ? values[f.id] : []
231
+ const checked = arr.includes(opt.value)
232
+ return (
233
+ <label key={opt.value} className="inline-flex items-center gap-2">
234
+ <input
235
+ type="checkbox"
236
+ checked={checked}
237
+ onChange={(e) => {
238
+ const next = new Set(arr)
239
+ if (e.target.checked) next.add(opt.value)
240
+ else next.delete(opt.value)
241
+ setValue(f.id, Array.from(next))
242
+ }}
243
+ />
244
+ <span className="text-sm">{opt.label}</span>
245
+ </label>
246
+ )
247
+ })}
248
+ </div>
249
+ ) : (
250
+ <select
251
+ className="w-full h-9 rounded border px-2 text-sm"
252
+ value={values[f.id] ?? ''}
253
+ onChange={(e) => setValue(f.id, e.target.value || undefined)}
254
+ >
255
+ <option value="">{t('ui.forms.select.emptyOption', '—')}</option>
256
+ {(f.options || dynamicOptions[f.id] || []).map((opt) => (
257
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
258
+ ))}
259
+ </select>
260
+ )}
261
+ </div>
262
+ )}
263
+ {f.type === 'tags' && (() => {
264
+ const arr: string[] = Array.isArray(values[f.id]) ? values[f.id] : []
265
+ const staticOptions = f.options || []
266
+ const dynamic = dynamicOptions[f.id] || []
267
+ const optionMap = new Map<string, FilterOption>()
268
+ staticOptions.forEach((opt) => optionMap.set(opt.value, opt))
269
+ dynamic.forEach((opt) => optionMap.set(opt.value, opt))
270
+ const loadSuggestions = tagLoaders.get(f.id)
271
+ const resolveTagLabel = f.formatValue
272
+ ? (val: string) => f.formatValue!(val)
273
+ : (val: string) => optionMap.get(val)?.label ?? val
274
+ const resolveTagDescription = f.formatDescription
275
+ ? (val: string) => f.formatDescription!(val) ?? null
276
+ : (val: string) => optionMap.get(val)?.description ?? null
277
+ const suggestionList: TagsInputOption[] = Array.from(optionMap.values()).map((opt) => ({
278
+ value: opt.value,
279
+ label: opt.label,
280
+ description: opt.description ?? null,
281
+ }))
282
+ return (
283
+ <TagsInput
284
+ value={arr}
285
+ suggestions={suggestionList}
286
+ loadSuggestions={loadSuggestions}
287
+ allowCustomValues={false}
288
+ resolveLabel={resolveTagLabel}
289
+ resolveDescription={resolveTagDescription}
290
+ placeholder={f.placeholder}
291
+ onChange={(next) => setValue(f.id, next.length ? next : undefined)}
292
+ />
293
+ )
294
+ })()}
295
+ {f.type === 'checkbox' && (
296
+ <div>
297
+ <select
298
+ className="w-full h-9 rounded border px-2 text-sm"
299
+ value={values[f.id] === true ? 'true' : values[f.id] === false ? 'false' : ''}
300
+ onChange={(e) => {
301
+ const v = e.target.value
302
+ if (v === '') setValue(f.id, undefined)
303
+ else if (v === 'true') setValue(f.id, true)
304
+ else if (v === 'false') setValue(f.id, false)
305
+ }}
306
+ >
307
+ <option value="">{t('ui.forms.select.emptyOption', '—')}</option>
308
+ <option value="true">{t('common.yes', 'Yes')}</option>
309
+ <option value="false">{t('common.no', 'No')}</option>
310
+ </select>
311
+ </div>
312
+ )}
313
+ </div>
314
+ ))}
315
+ </div>
316
+ <div className="p-4 border-t flex items-center justify-between gap-2">
317
+ <Button variant="outline" onClick={handleClear}>Clear</Button>
318
+ <Button onClick={handleApply} className="inline-flex items-center gap-2">
319
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" className="opacity-80"><path d="M3 4h18"/><path d="M6 8h12l-3 8H9L6 8z"/></svg>
320
+ Apply
321
+ </Button>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ )}
326
+ </>
327
+ )
328
+ }
@@ -0,0 +1,82 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { usePathname, useSearchParams } from 'next/navigation'
4
+
5
+ export type FlashKind = 'success' | 'error' | 'warning' | 'info'
6
+
7
+ // Programmatic API to show a flash message without navigation.
8
+ // Consumers can import { flash } and call flash('text', 'error').
9
+ export function flash(message: string, type: FlashKind = 'info') {
10
+ if (typeof window === 'undefined') return
11
+ const evt = new CustomEvent('flash', { detail: { message, type } })
12
+ window.dispatchEvent(evt)
13
+ }
14
+
15
+ function FlashMessagesInner() {
16
+ const [msg, setMsg] = React.useState<string | null>(null)
17
+ const [kind, setKind] = React.useState<FlashKind>('info')
18
+ const pathname = usePathname()
19
+ const searchParams = useSearchParams()
20
+
21
+ // Read flash from URL on any navigation change (client-side too)
22
+ React.useEffect(() => {
23
+ if (!searchParams) return
24
+ const m = searchParams.get('flash')
25
+ const t = (searchParams.get('type') as FlashKind | null) || 'success'
26
+ if (m) {
27
+ setMsg(m)
28
+ setKind(t)
29
+ const url = new URL(window.location.href)
30
+ url.searchParams.delete('flash')
31
+ url.searchParams.delete('type')
32
+ window.history.replaceState({}, '', url.toString())
33
+ const timer = setTimeout(() => setMsg(null), 3000)
34
+ return () => clearTimeout(timer)
35
+ }
36
+ }, [pathname, searchParams])
37
+
38
+ // Listen for programmatic flash events
39
+ React.useEffect(() => {
40
+ const handler = (e: Event) => {
41
+ const ce = e as CustomEvent<{ message?: string; type?: FlashKind }>
42
+ const text = ce.detail?.message
43
+ const t = ce.detail?.type || 'info'
44
+ if (!text) return
45
+ setMsg(text)
46
+ setKind(t)
47
+ const timer = setTimeout(() => setMsg(null), 3000)
48
+ return () => clearTimeout(timer)
49
+ }
50
+ window.addEventListener('flash', handler as EventListener)
51
+ return () => window.removeEventListener('flash', handler as EventListener)
52
+ }, [])
53
+
54
+ if (!msg) return null
55
+
56
+ const color = kind === 'success' ? 'bg-emerald-600' : kind === 'error' ? 'bg-red-600' : kind === 'warning' ? 'bg-amber-500' : 'bg-blue-600'
57
+
58
+ return (
59
+ <div className="pointer-events-none fixed left-3 right-3 top-3 z-[1200] sm:left-auto sm:right-4 sm:w-[380px]">
60
+ <div className={`pointer-events-auto rounded px-3 py-2 text-white shadow-md ${color}`}>
61
+ <div className="flex items-center justify-between gap-2">
62
+ <div className="text-sm">{msg}</div>
63
+ <button
64
+ type="button"
65
+ className="text-sm text-white/90 transition hover:text-white"
66
+ onClick={() => setMsg(null)}
67
+ >
68
+ ×
69
+ </button>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ )
74
+ }
75
+
76
+ export function FlashMessages() {
77
+ return (
78
+ <React.Suspense fallback={null}>
79
+ <FlashMessagesInner />
80
+ </React.Suspense>
81
+ )
82
+ }