@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,245 @@
1
+ import * as React from 'react'
2
+ import { useQuery, type UseQueryResult, type QueryClient } from '@tanstack/react-query'
3
+ import { readApiResultOrThrow } from './apiCall'
4
+ import type { CustomFieldOptionDto } from '@open-mercato/shared/modules/entities/options'
5
+
6
+ export type CustomFieldDefDto = {
7
+ entityId?: string
8
+ key: string
9
+ kind: string
10
+ label?: string
11
+ description?: string
12
+ options?: CustomFieldOptionDto[]
13
+ optionsUrl?: string
14
+ multi?: boolean
15
+ filterable?: boolean
16
+ formEditable?: boolean
17
+ listVisible?: boolean
18
+ editor?: string
19
+ input?: string
20
+ priority?: number
21
+ fieldset?: string
22
+ group?: { code: string; title?: string; hint?: string }
23
+ // attachments-specific config
24
+ maxAttachmentSizeMb?: number
25
+ acceptExtensions?: string[]
26
+ // optional validation rules
27
+ validation?: Array<
28
+ | { rule: 'required'; message: string }
29
+ | { rule: 'date'; message: string }
30
+ | { rule: 'integer'; message: string }
31
+ | { rule: 'float'; message: string }
32
+ | { rule: 'lt' | 'lte' | 'gt' | 'gte'; param: number; message: string }
33
+ | { rule: 'eq' | 'ne'; param: any; message: string }
34
+ | { rule: 'regex'; param: string; message: string }
35
+ >
36
+ dictionaryId?: string
37
+ dictionaryInlineCreate?: boolean
38
+ }
39
+
40
+ export type CustomFieldsetGroupDto = {
41
+ code: string
42
+ title?: string
43
+ hint?: string
44
+ }
45
+
46
+ export type CustomFieldsetDto = {
47
+ code: string
48
+ label: string
49
+ icon?: string
50
+ description?: string
51
+ groups?: CustomFieldsetGroupDto[]
52
+ }
53
+
54
+ export type CustomFieldDefinitionsPayload = {
55
+ items?: CustomFieldDefDto[]
56
+ fieldsetsByEntity?: Record<string, CustomFieldsetDto[]>
57
+ entitySettings?: Record<string, { singleFieldsetPerRecord?: boolean }>
58
+ }
59
+
60
+ export function normalizeEntityIds(entityIds: string | string[] | null | undefined): string[] {
61
+ if (entityIds == null) return []
62
+ const list = Array.isArray(entityIds) ? entityIds : [entityIds]
63
+ const dedup = new Set<string>()
64
+ const normalized: string[] = []
65
+ for (const raw of list) {
66
+ const trimmed = String(raw ?? '').trim()
67
+ if (!trimmed || dedup.has(trimmed)) continue
68
+ dedup.add(trimmed)
69
+ normalized.push(trimmed)
70
+ }
71
+ return normalized
72
+ }
73
+
74
+ export type CustomFieldDefinitionQueryOptions = {
75
+ fieldset?: string
76
+ }
77
+
78
+ function buildDefinitionsQuery(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): string {
79
+ const params = new URLSearchParams()
80
+ entityIds.forEach((id) => {
81
+ if (id) params.append('entityId', id)
82
+ })
83
+ if (options?.fieldset) params.set('fieldset', options.fieldset)
84
+ return params.toString()
85
+ }
86
+
87
+ type CustomFieldDefinitionsResponse = CustomFieldDefinitionsPayload
88
+
89
+ function normalizeRecord<T>(value: unknown): Record<string, T[]> {
90
+ if (!value || typeof value !== 'object') return {}
91
+ const out: Record<string, T[]> = {}
92
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
93
+ if (!Array.isArray(raw)) continue
94
+ out[key] = raw as T[]
95
+ }
96
+ return out
97
+ }
98
+
99
+ function preparePayload(data: CustomFieldDefinitionsResponse | null | undefined): CustomFieldDefinitionsPayload {
100
+ const items = Array.isArray(data?.items) ? [...data!.items] : []
101
+ items.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
102
+ const fieldsetsByEntity = normalizeRecord<CustomFieldsetDto>(data?.fieldsetsByEntity)
103
+ const entitySettings = (data?.entitySettings && typeof data.entitySettings === 'object'
104
+ ? data.entitySettings
105
+ : {}) as CustomFieldDefinitionsPayload['entitySettings']
106
+ return { items, fieldsetsByEntity, entitySettings }
107
+ }
108
+
109
+ async function readDefinitionsViaFetch(
110
+ entityIds: string[],
111
+ fetchImpl: typeof fetch,
112
+ options?: CustomFieldDefinitionQueryOptions,
113
+ ): Promise<CustomFieldDefinitionsPayload> {
114
+ const query = buildDefinitionsQuery(entityIds, options)
115
+ const res = await fetchImpl(`/api/entities/definitions?${query}`, {
116
+ headers: { 'content-type': 'application/json' },
117
+ })
118
+ const data = await res.json().catch(() => ({ items: [] }))
119
+ return preparePayload(data)
120
+ }
121
+
122
+ async function readDefinitionsViaApi(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): Promise<CustomFieldDefinitionsPayload> {
123
+ const query = buildDefinitionsQuery(entityIds, options)
124
+ const payload = await readApiResultOrThrow<CustomFieldDefinitionsResponse>(
125
+ `/api/entities/definitions?${query}`,
126
+ { headers: { 'content-type': 'application/json' } },
127
+ {
128
+ errorMessage: 'Failed to load custom field definitions',
129
+ fallback: { items: [] },
130
+ },
131
+ )
132
+ return preparePayload(payload)
133
+ }
134
+
135
+ export async function fetchCustomFieldDefinitionsPayload(
136
+ entityIds: string | string[],
137
+ fetchImpl?: typeof fetch,
138
+ options?: CustomFieldDefinitionQueryOptions,
139
+ ): Promise<CustomFieldDefinitionsPayload> {
140
+ const filtered = normalizeEntityIds(entityIds)
141
+ if (!filtered.length) return { items: [] }
142
+ return fetchImpl
143
+ ? await readDefinitionsViaFetch(filtered, fetchImpl, options)
144
+ : await readDefinitionsViaApi(filtered, options)
145
+ }
146
+
147
+ export async function fetchCustomFieldDefs(
148
+ entityIds: string | string[],
149
+ fetchImpl?: typeof fetch,
150
+ options?: CustomFieldDefinitionQueryOptions,
151
+ ): Promise<CustomFieldDefDto[]> {
152
+ const payload = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl, options)
153
+ return payload.items ?? []
154
+ }
155
+
156
+ export type UseCustomFieldDefsOptions<TData = CustomFieldDefDto[]> = {
157
+ enabled?: boolean
158
+ staleTime?: number
159
+ gcTime?: number
160
+ /** @deprecated Custom fetch implementations are no longer needed. */
161
+ fetchImpl?: typeof fetch
162
+ keyExtras?: Array<string | number | boolean | null | undefined>
163
+ fieldset?: string
164
+ select?: (data: CustomFieldDefDto[]) => TData
165
+ }
166
+
167
+ export function useCustomFieldDefs<TData = CustomFieldDefDto[]>(
168
+ entityIds: string | string[] | null | undefined,
169
+ options: UseCustomFieldDefsOptions<TData> = {}
170
+ ): UseQueryResult<TData> {
171
+ const {
172
+ enabled: enabledOption = true,
173
+ staleTime,
174
+ gcTime,
175
+ keyExtras,
176
+ fetchImpl,
177
+ fieldset,
178
+ } = options
179
+ const normalizedIds = React.useMemo(() => normalizeEntityIds(entityIds), [entityIds])
180
+ const idsSignature = React.useMemo(() => JSON.stringify(normalizedIds), [normalizedIds])
181
+ const extrasSignature = React.useMemo(() => JSON.stringify(keyExtras ?? []), [keyExtras])
182
+ const normalizedFieldset = typeof fieldset === 'string' && fieldset.trim().length ? fieldset.trim() : null
183
+ const queryKey = React.useMemo(
184
+ () => ['customFieldDefs', ...(keyExtras ?? []), ...normalizedIds, `fieldset:${normalizedFieldset ?? 'default'}`],
185
+ [idsSignature, extrasSignature, normalizedFieldset]
186
+ )
187
+ const enabled = enabledOption && normalizedIds.length > 0
188
+
189
+ return useQuery<CustomFieldDefDto[], Error, TData>({
190
+ queryKey,
191
+ queryFn: () =>
192
+ fetchCustomFieldDefs(
193
+ normalizedIds,
194
+ fetchImpl,
195
+ normalizedFieldset ? { fieldset: normalizedFieldset } : undefined
196
+ ),
197
+ enabled,
198
+ staleTime: staleTime ?? 5 * 60 * 1000,
199
+ gcTime: gcTime ?? 30 * 60 * 1000,
200
+ select: options.select,
201
+ })
202
+ }
203
+
204
+ export type CustomFieldVisibility = 'list' | 'form' | 'filter'
205
+
206
+ export function isDefVisible(def: CustomFieldDefDto, mode: CustomFieldVisibility): boolean {
207
+ switch (mode) {
208
+ case 'list':
209
+ return def.listVisible !== false
210
+ case 'form':
211
+ return def.formEditable !== false
212
+ case 'filter':
213
+ return !!def.filterable
214
+ default:
215
+ return true
216
+ }
217
+ }
218
+
219
+ export function filterCustomFieldDefs(defs: CustomFieldDefDto[], mode: CustomFieldVisibility): CustomFieldDefDto[] {
220
+ return defs
221
+ .filter((d) => isDefVisible(d, mode))
222
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
223
+ }
224
+
225
+ export async function invalidateCustomFieldDefs(
226
+ queryClient: QueryClient,
227
+ entityIds?: string | string[] | null,
228
+ ): Promise<void> {
229
+ const normalizedIds = normalizeEntityIds(entityIds)
230
+ const targetPrefixes = new Set(['customFieldDefs', 'customFieldForms', 'dealFormFields'])
231
+ if (!normalizedIds.length) {
232
+ await queryClient.invalidateQueries({
233
+ predicate: (query) => Array.isArray(query.queryKey) && typeof query.queryKey[0] === 'string' && targetPrefixes.has(query.queryKey[0] as string),
234
+ })
235
+ return
236
+ }
237
+ await queryClient.invalidateQueries({
238
+ predicate: (query) => {
239
+ if (!Array.isArray(query.queryKey)) return false
240
+ const [prefix] = query.queryKey
241
+ if (typeof prefix !== 'string' || !targetPrefixes.has(prefix)) return false
242
+ return normalizedIds.every((id) => query.queryKey.includes(id))
243
+ },
244
+ })
245
+ }
@@ -0,0 +1,145 @@
1
+ import * as React from 'react'
2
+ import { useCustomFieldDefs, type UseCustomFieldDefsOptions } from './customFieldDefs'
3
+ import { Filter } from '@open-mercato/shared/lib/query/types'
4
+ import type { FilterDef } from '../FilterOverlay'
5
+ import type { CustomFieldDefDto } from './customFieldDefs'
6
+ export type { CustomFieldDefDto }
7
+ import { filterCustomFieldDefs, fetchCustomFieldDefs as loadCustomFieldDefs } from './customFieldDefs'
8
+ import { type UseQueryResult } from '@tanstack/react-query'
9
+ import { apiCall } from './apiCall'
10
+ import { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'
11
+
12
+ function buildOptionsUrl(base: string, query?: string): string {
13
+ if (!query) return base
14
+ try {
15
+ const isAbsolute = /^([a-z][a-z\d+\-.]*:)?\/\//i.test(base)
16
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
17
+ const url = isAbsolute ? new URL(base) : new URL(base, origin)
18
+ if (!url.searchParams.has('query')) url.searchParams.append('query', query)
19
+ if (!url.searchParams.has('q')) url.searchParams.append('q', query)
20
+ if (isAbsolute) return url.toString()
21
+ return `${url.pathname}${url.search}`
22
+ } catch {
23
+ const sep = base.includes('?') ? '&' : '?'
24
+ if (base.includes('query=')) return `${base}${sep}q=${encodeURIComponent(query)}`
25
+ return `${base}${sep}query=${encodeURIComponent(query)}`
26
+ }
27
+ }
28
+
29
+ type OptionsResponse = { items?: unknown[] }
30
+
31
+ async function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {
32
+ try {
33
+ const call = await apiCall<OptionsResponse>(url, undefined, { fallback: { items: [] } })
34
+ if (!call.ok) return []
35
+ const payload = call.result ?? { items: [] }
36
+ const items = Array.isArray(payload?.items) ? payload.items : []
37
+ return items.map((it: any) => ({
38
+ value: String(it?.value ?? it),
39
+ label: String(it?.label ?? it?.value ?? it),
40
+ }))
41
+ } catch {
42
+ return []
43
+ }
44
+ }
45
+
46
+ type RawOption = string | number | { value?: unknown; label?: unknown }
47
+
48
+ function normalizeOptions(options?: RawOption[]): Array<{ value: string; label: string }> {
49
+ if (!Array.isArray(options)) return []
50
+ return options.map((option) => {
51
+ if (option && typeof option === 'object' && 'value' in option) {
52
+ const rawValue = (option as any).value
53
+ const rawLabel = (option as any).label ?? rawValue
54
+ const value = String(rawValue)
55
+ const label = typeof rawLabel === 'string' ? rawLabel : String(rawLabel)
56
+ return { value, label }
57
+ }
58
+ const value = String(option)
59
+ return { value, label: value.charAt(0).toUpperCase() + value.slice(1) }
60
+ })
61
+ }
62
+
63
+ export function buildFilterDefsFromCustomFields(defs: CustomFieldDefDto[]): FilterDef[] {
64
+ const f: FilterDef[] = []
65
+ const visible = filterCustomFieldDefs(defs, 'filter')
66
+ const seenKeys = new Set<string>() // case-insensitive de-dupe by key
67
+ for (const d of visible) {
68
+ const keyLower = String(d.key).toLowerCase()
69
+ if (seenKeys.has(keyLower)) continue
70
+ seenKeys.add(keyLower)
71
+ const id = `cf_${d.key}`
72
+ const label = d.label || d.key
73
+ if (d.kind === 'boolean') {
74
+ f.push({ id, label, type: 'checkbox' })
75
+ } else if (d.kind === 'select' || d.kind === 'currency' || d.kind === 'relation' || d.kind === 'dictionary') {
76
+ const options = normalizeOptions(d.options)
77
+ const base: FilterDef = { id: d.multi ? `${id}In` : id, label, type: 'select', multiple: !!d.multi, options }
78
+ // When optionsUrl is provided, allow async options loading for filters too
79
+ const optionsUrl = d.kind === 'currency' ? CURRENCY_OPTIONS_URL : d.optionsUrl
80
+ if (optionsUrl) {
81
+ ;(base as FilterDef).loadOptions = async (query?: string) => {
82
+ const url = buildOptionsUrl(optionsUrl, query)
83
+ return loadRemoteOptions(url)
84
+ }
85
+ }
86
+ f.push(base)
87
+ } else if (d.kind === 'text' && d.multi) {
88
+ // Multi-text custom field → use tags input in filters
89
+ const base: FilterDef = {
90
+ id: `${id}In`,
91
+ label,
92
+ type: 'tags',
93
+ // If static options provided, pass them for suggestions
94
+ options: normalizeOptions(d.options),
95
+ } as any
96
+ // Enable async suggestions when optionsUrl provided
97
+ if (d.optionsUrl) {
98
+ ;(base as any).loadOptions = async (query?: string) => {
99
+ const url = buildOptionsUrl(d.optionsUrl!, query)
100
+ return loadRemoteOptions(url)
101
+ }
102
+ }
103
+ f.push(base)
104
+ } else {
105
+ f.push({ id, label, type: 'text' })
106
+ }
107
+ }
108
+ // De-duplicate by id in case of overlaps; keep first occurrence
109
+ const out: FilterDef[] = []
110
+ const seen = new Set<string>()
111
+ for (const item of f) {
112
+ if (seen.has(item.id)) continue
113
+ seen.add(item.id)
114
+ out.push(item)
115
+ }
116
+ // Preserve the original visible order (already sorted by priority) by mapping back
117
+ const order = new Map(visible.map((v, idx) => [v.key, idx]))
118
+ out.sort((a, b) => (order.get(a.id.replace(/^cf_/, '').replace(/In$/, '')) ?? 0) - (order.get(b.id.replace(/^cf_/, '').replace(/In$/, '')) ?? 0))
119
+ return out
120
+ }
121
+
122
+ export async function fetchCustomFieldFilterDefs(
123
+ entityIds: string | string[],
124
+ fetchImpl?: typeof fetch,
125
+ options?: { fieldset?: string },
126
+ ): Promise<FilterDef[]> {
127
+ const defs: CustomFieldDefDto[] = await loadCustomFieldDefs(
128
+ entityIds,
129
+ fetchImpl,
130
+ options?.fieldset ? { fieldset: options.fieldset } : undefined,
131
+ )
132
+ return buildFilterDefsFromCustomFields(defs)
133
+ }
134
+
135
+ export function useCustomFieldFilterDefs(
136
+ entityIds: string | string[] | null | undefined,
137
+ options: UseCustomFieldDefsOptions<FilterDef[]> = {}
138
+ ): UseQueryResult<FilterDef[]> {
139
+ const { select, ...rest } = options
140
+ const selectFn = React.useCallback(
141
+ (defs: CustomFieldDefDto[]) => (select ? select(defs) : buildFilterDefsFromCustomFields(defs)),
142
+ [select]
143
+ )
144
+ return useCustomFieldDefs<FilterDef[]>(entityIds, { ...rest, select: selectFn })
145
+ }
@@ -0,0 +1,196 @@
1
+ import type { CrudField } from '../CrudForm'
2
+ import type {
3
+ CustomFieldDefDto,
4
+ CustomFieldDefinitionsPayload,
5
+ } from './customFieldDefs'
6
+ import {
7
+ filterCustomFieldDefs,
8
+ fetchCustomFieldDefs,
9
+ fetchCustomFieldDefinitionsPayload,
10
+ } from './customFieldDefs'
11
+ import { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'
12
+ import { apiCall } from './apiCall'
13
+ import { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'
14
+ import { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'
15
+
16
+ let registryReady: Promise<void> | null = null
17
+
18
+ async function ensureFieldRegistryReady() {
19
+ if (!registryReady) {
20
+ registryReady = loadGeneratedFieldRegistrations().catch((err) => {
21
+ registryReady = null
22
+ throw err
23
+ })
24
+ }
25
+ await registryReady
26
+ }
27
+
28
+ function buildOptionsUrl(base: string, query?: string): string {
29
+ if (!query) return base
30
+ try {
31
+ const isAbsolute = /^([a-z][a-z\d+\-.]*:)?\/\//i.test(base)
32
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
33
+ const url = isAbsolute ? new URL(base) : new URL(base, origin)
34
+ if (!url.searchParams.has('query')) url.searchParams.append('query', query)
35
+ if (!url.searchParams.has('q')) url.searchParams.append('q', query)
36
+ if (isAbsolute) return url.toString()
37
+ return `${url.pathname}${url.search}`
38
+ } catch {
39
+ const sep = base.includes('?') ? '&' : '?'
40
+ if (base.includes('query=')) return `${base}${sep}q=${encodeURIComponent(query)}`
41
+ return `${base}${sep}query=${encodeURIComponent(query)}`
42
+ }
43
+ }
44
+
45
+ type OptionsResponse = { items?: unknown[] }
46
+
47
+ async function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {
48
+ try {
49
+ const call = await apiCall<OptionsResponse>(url, undefined, { fallback: { items: [] } })
50
+ if (!call.ok) return []
51
+ const payload = call.result ?? { items: [] }
52
+ const items = Array.isArray(payload?.items) ? payload.items : []
53
+ return items.map((it: any) => ({
54
+ value: String(it?.value ?? it),
55
+ label: String(it?.label ?? it?.value ?? it),
56
+ }))
57
+ } catch {
58
+ return []
59
+ }
60
+ }
61
+
62
+ export function buildFormFieldFromCustomFieldDef(
63
+ def: CustomFieldDefDto,
64
+ opts?: { bareIds?: boolean }
65
+ ): CrudField | null {
66
+ const id = opts?.bareIds ? def.key : `cf_${def.key}`
67
+ const label = def.label || def.key
68
+ const required = Array.isArray((def as any).validation)
69
+ ? ((def as any).validation as any[]).some((rule) => rule && rule.rule === 'required')
70
+ : false
71
+
72
+ switch (def.kind) {
73
+ case 'boolean':
74
+ return { id, label, type: 'checkbox', description: def.description, required }
75
+ case 'integer':
76
+ case 'float':
77
+ return { id, label, type: 'number', description: def.description, required }
78
+ case 'multiline': {
79
+ let editor: 'simple' | 'uiw' | 'html' = 'uiw'
80
+ if (def.editor === 'simpleMarkdown') editor = 'simple'
81
+ else if (def.editor === 'htmlRichText') editor = 'html'
82
+ return { id, label, type: 'richtext', description: def.description, editor, required }
83
+ }
84
+ case 'select':
85
+ case 'currency':
86
+ case 'relation':
87
+ return {
88
+ id,
89
+ label,
90
+ type: 'select',
91
+ description: def.description,
92
+ options: normalizeCustomFieldOptions(def.options || []).map((option) => ({
93
+ value: option.value,
94
+ label: option.label,
95
+ })),
96
+ multiple: !!def.multi,
97
+ required,
98
+ ...(def.kind === 'currency'
99
+ ? {
100
+ loadOptions: async (query?: string) => {
101
+ const url = buildOptionsUrl(CURRENCY_OPTIONS_URL, query)
102
+ return loadRemoteOptions(url)
103
+ },
104
+ }
105
+ : def.optionsUrl
106
+ ? {
107
+ loadOptions: async (query?: string) => {
108
+ const url = buildOptionsUrl(def.optionsUrl!, query)
109
+ return loadRemoteOptions(url)
110
+ },
111
+ }
112
+ : {}),
113
+ ...(def.multi && def.input === 'listbox' ? ({ listbox: true } as any) : {}),
114
+ }
115
+ default: {
116
+ if (def.kind === 'text' && def.multi) {
117
+ const base: any = { id, label, type: 'tags', description: def.description, required }
118
+ const resolvedOptions = normalizeCustomFieldOptions(def.options || [])
119
+ if (resolvedOptions.length > 0) {
120
+ base.options = resolvedOptions.map((option) => ({ value: option.value, label: option.label }))
121
+ }
122
+ if (def.optionsUrl) {
123
+ base.loadOptions = async (query?: string) => {
124
+ const url = buildOptionsUrl(def.optionsUrl!, query)
125
+ return loadRemoteOptions(url)
126
+ }
127
+ }
128
+ return base
129
+ }
130
+ if (def.kind === 'text' && typeof def.editor === 'string' && def.editor) {
131
+ let editor: 'simple' | 'uiw' | 'html' = 'uiw'
132
+ if (def.editor === 'simpleMarkdown') editor = 'simple'
133
+ else if (def.editor === 'htmlRichText') editor = 'html'
134
+ return { id, label, type: 'richtext', description: def.description, editor, required }
135
+ }
136
+ const input = FieldRegistry.getInput(def.kind)
137
+ if (input) {
138
+ return {
139
+ id,
140
+ label,
141
+ type: 'custom',
142
+ required,
143
+ description: def.description,
144
+ component: (props) => input({ ...props, def }),
145
+ }
146
+ }
147
+ return { id, label, type: 'text', description: def.description, required }
148
+ }
149
+ }
150
+ }
151
+
152
+ export function buildFormFieldsFromCustomFields(
153
+ defs: CustomFieldDefDto[],
154
+ opts?: { bareIds?: boolean }
155
+ ): CrudField[] {
156
+ const fields: CrudField[] = []
157
+ const visible = filterCustomFieldDefs(defs, 'form')
158
+ const seenKeys = new Set<string>()
159
+ for (const def of visible) {
160
+ const keyLower = String(def.key).toLowerCase()
161
+ if (seenKeys.has(keyLower)) continue
162
+ seenKeys.add(keyLower)
163
+ const field = buildFormFieldFromCustomFieldDef(def, opts)
164
+ if (field) fields.push(field)
165
+ }
166
+ return fields
167
+ }
168
+
169
+ export async function fetchCustomFieldFormStructure(
170
+ entityIds: string | string[],
171
+ fetchImpl?: typeof fetch,
172
+ options?: { bareIds?: boolean },
173
+ ): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {
174
+ await ensureFieldRegistryReady()
175
+ const metadata = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl)
176
+ const definitions = Array.isArray(metadata.items) ? metadata.items : []
177
+ const fields = buildFormFieldsFromCustomFields(definitions, options)
178
+ return { fields, definitions, metadata }
179
+ }
180
+
181
+ export async function fetchCustomFieldFormFields(
182
+ entityIds: string | string[],
183
+ fetchImpl?: typeof fetch,
184
+ options?: { bareIds?: boolean },
185
+ ): Promise<CrudField[]> {
186
+ const { fields } = await fetchCustomFieldFormStructure(entityIds, fetchImpl, options)
187
+ return fields
188
+ }
189
+
190
+ export async function fetchCustomFieldFormFieldsWithDefinitions(
191
+ entityIds: string | string[],
192
+ fetchImpl?: typeof fetch,
193
+ options?: { bareIds?: boolean },
194
+ ): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {
195
+ return fetchCustomFieldFormStructure(entityIds, fetchImpl, options)
196
+ }
@@ -0,0 +1,41 @@
1
+ export type CollectCustomFieldOptions = {
2
+ prefixes?: string[]
3
+ stripPrefix?: boolean
4
+ transform?: (value: unknown, fieldId: string, rawKey: string) => unknown
5
+ accept?: (fieldId: string, rawKey: string, value: unknown) => boolean
6
+ omitUndefined?: boolean
7
+ }
8
+
9
+ const DEFAULT_PREFIXES = ['cf_', 'cf:']
10
+
11
+ export function collectCustomFieldValues(
12
+ values: Record<string, unknown>,
13
+ options: CollectCustomFieldOptions = {},
14
+ ): Record<string, unknown> {
15
+ const {
16
+ prefixes = DEFAULT_PREFIXES,
17
+ stripPrefix = true,
18
+ transform,
19
+ accept,
20
+ omitUndefined = true,
21
+ } = options
22
+
23
+ const result: Record<string, unknown> = {}
24
+
25
+ for (const [rawKey, rawValue] of Object.entries(values)) {
26
+ const prefix = prefixes.find((candidate) => rawKey.startsWith(candidate))
27
+ if (!prefix) continue
28
+
29
+ const fieldId = stripPrefix ? rawKey.slice(prefix.length) : rawKey
30
+ if (!fieldId) continue
31
+
32
+ if (accept && !accept(fieldId, rawKey, rawValue)) continue
33
+
34
+ const nextValue = transform ? transform(rawValue, fieldId, rawKey) : rawValue
35
+ if (omitUndefined && nextValue === undefined) continue
36
+
37
+ result[fieldId] = nextValue
38
+ }
39
+
40
+ return result
41
+ }
@@ -0,0 +1,17 @@
1
+ export type FlashType = 'success' | 'error' | 'warning' | 'info'
2
+
3
+ // Append flash message and type to a URL (relative or absolute) and return a relative URL string.
4
+ export function withFlash(url: string, message: string, type: FlashType = 'success'): string {
5
+ const base = typeof window !== 'undefined' && window.location ? window.location.origin : 'http://localhost'
6
+ const u = new URL(url, base)
7
+ u.searchParams.set('flash', message)
8
+ u.searchParams.set('type', type)
9
+ const qs = u.searchParams.toString()
10
+ return `${u.pathname}${qs ? `?${qs}` : ''}`
11
+ }
12
+
13
+ // Helper to push a URL with flash via Next.js router
14
+ export function pushWithFlash(router: { push: (href: string) => any }, url: string, message: string, type: FlashType = 'success') {
15
+ router.push(withFlash(url, message, type))
16
+ }
17
+