@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,238 @@
1
+ import type { ReactNode } from 'react'
2
+ import React from 'react'
3
+
4
+ export type AdminNavItem = {
5
+ group: string
6
+ groupId: string
7
+ groupKey?: string
8
+ groupDefaultName: string
9
+ title: string
10
+ defaultTitle: string
11
+ titleKey?: string
12
+ href: string
13
+ enabled: boolean
14
+ hidden?: boolean
15
+ order?: number
16
+ priority?: number
17
+ icon?: ReactNode
18
+ children?: AdminNavItem[]
19
+ }
20
+
21
+ export type AdminNavFeatureChecker = (features: string[]) => Promise<Iterable<string> | null | undefined>
22
+
23
+ export type BuildAdminNavOptions = {
24
+ checkFeatures?: AdminNavFeatureChecker
25
+ }
26
+
27
+ /**
28
+ * @deprecated The internal fetch-based feature check will be removed.
29
+ * Provide `options.checkFeatures` so buildAdminNav can reuse your RBAC context.
30
+ */
31
+ async function fetchFeatureGrants(requestFeatures: string[]): Promise<Set<string>> {
32
+ const granted = new Set<string>()
33
+ if (!requestFeatures.length) return granted
34
+ let url = '/api/auth/feature-check'
35
+ let headersInit: Record<string, string> | undefined
36
+ if (typeof window === 'undefined') {
37
+ // On the server, build absolute URL and forward cookies so auth is available
38
+ try {
39
+ const { headers: getHeaders } = await import('next/headers')
40
+ const h = await getHeaders()
41
+ const host = h.get('x-forwarded-host') || h.get('host') || ''
42
+ const proto = h.get('x-forwarded-proto') || 'http'
43
+ const cookie = h.get('cookie') || ''
44
+ if (host) url = `${proto}://${host}/api/auth/feature-check`
45
+ headersInit = { cookie }
46
+ } catch {
47
+ // ignore; fall back to relative URL without forwarded cookies
48
+ }
49
+ }
50
+ try {
51
+ const res = await fetch(url, {
52
+ method: 'POST',
53
+ credentials: 'include' as any,
54
+ headers: { 'content-type': 'application/json', ...(headersInit || {}) },
55
+ body: JSON.stringify({ features: requestFeatures }),
56
+ } as any)
57
+ if (res.ok) {
58
+ const data = await res.json().catch(() => ({ granted: [] }))
59
+ if (Array.isArray(data?.granted)) {
60
+ data.granted.forEach((f: string) => granted.add(f))
61
+ }
62
+ }
63
+ } catch {
64
+ // ignore fetch failures and keep feature set empty
65
+ }
66
+ return granted
67
+ }
68
+
69
+ export async function buildAdminNav(
70
+ modules: any[],
71
+ ctx: { auth?: { roles?: string[]; sub?: string; orgId?: string | null; tenantId?: string | null }; path?: string },
72
+ userEntities?: Array<{ entityId: string; label: string; href: string }>,
73
+ translate?: (key: string | undefined, fallback: string) => string,
74
+ options?: BuildAdminNavOptions
75
+ ): Promise<AdminNavItem[]> {
76
+ function capitalize(s: string) {
77
+ return s.charAt(0).toUpperCase() + s.slice(1)
78
+ }
79
+ function deriveTitleFromPath(p: string) {
80
+ const seg = p.split('/').filter(Boolean).pop() || ''
81
+ return seg ? seg.split('-').map(capitalize).join(' ') : 'Home'
82
+ }
83
+ const entries: AdminNavItem[] = []
84
+
85
+ // Collect all unique features needed across all routes first
86
+ const allRequiredFeatures = new Set<string>()
87
+ for (const m of modules) {
88
+ for (const r of m.backendRoutes ?? []) {
89
+ const features = (r as any).requireFeatures as string[] | undefined
90
+ if (features && features.length) {
91
+ features.forEach(f => allRequiredFeatures.add(f))
92
+ }
93
+ }
94
+ }
95
+
96
+ // Batch check all features in a single API call
97
+ let userFeatures = new Set<string>()
98
+ if (allRequiredFeatures.size > 0) {
99
+ const requestFeatures = Array.from(allRequiredFeatures)
100
+ if (options?.checkFeatures) {
101
+ try {
102
+ const resolved = await options.checkFeatures(requestFeatures)
103
+ if (resolved) {
104
+ userFeatures = new Set(resolved)
105
+ }
106
+ } catch {
107
+ // ignore and fall back to empty feature set
108
+ }
109
+ } else {
110
+ userFeatures = await fetchFeatureGrants(requestFeatures)
111
+ }
112
+ }
113
+
114
+ // Helper: check if user has all required features (from cache)
115
+ function hasAllFeatures(required: string[]): boolean {
116
+ if (!required || required.length === 0) return true
117
+ return required.every(f => userFeatures.has(f))
118
+ }
119
+
120
+ // Icons are defined per-page in metadata; no heuristic derivation here.
121
+ for (const m of modules) {
122
+ const groupDefault = capitalize(m.id)
123
+ for (const r of m.backendRoutes ?? []) {
124
+ const href = (r.pattern ?? r.path ?? '') as string
125
+ if (!href || href.includes('[')) continue
126
+ if ((r as any).navHidden) continue
127
+ const title = (r.title as string) || deriveTitleFromPath(href)
128
+ const titleKey = (r as any).pageTitleKey ?? (r as any).titleKey
129
+ const group = (r.group as string) || groupDefault
130
+ const groupKey = (r as any).pageGroupKey ?? (r as any).groupKey
131
+ const groupId = (groupKey as string | undefined) ?? group
132
+ const displayGroup = translate ? translate(groupKey, group) : group
133
+ const displayTitle = translate ? translate(titleKey, title) : title
134
+ const visible = r.visible ? await Promise.resolve(r.visible(ctx)) : true
135
+ if (!visible) continue
136
+ const enabled = r.enabled ? await Promise.resolve(r.enabled(ctx)) : true
137
+ // If roles are required, check; otherwise include
138
+ const required = (r.requireRoles as string[]) || []
139
+ if (required.length) {
140
+ const roles = ctx.auth?.roles || []
141
+ const ok = required.some((role) => roles.includes(role))
142
+ if (!ok) continue
143
+ }
144
+ // If features are required, check from cached batch result
145
+ const features = (r as any).requireFeatures as string[] | undefined
146
+ if (features && features.length) {
147
+ const ok = hasAllFeatures(features)
148
+ if (!ok) continue
149
+ }
150
+ const order = (r as any).order as number | undefined
151
+ const priority = ((r as any).priority as number | undefined) ?? order
152
+ let icon = (r as any).icon as ReactNode | undefined
153
+ entries.push({
154
+ group: displayGroup,
155
+ groupId,
156
+ groupKey,
157
+ groupDefaultName: displayGroup,
158
+ title: displayTitle,
159
+ defaultTitle: displayTitle,
160
+ titleKey,
161
+ href,
162
+ enabled,
163
+ order,
164
+ priority,
165
+ icon,
166
+ })
167
+ }
168
+ }
169
+ // Build hierarchy: treat routes whose href starts with a parent href + '/'
170
+ const byHref = new Map<string, AdminNavItem>()
171
+ for (const e of entries) byHref.set(e.href, e)
172
+ const roots: AdminNavItem[] = []
173
+ for (const e of entries) {
174
+ // Find the longest parent href that is a strict prefix and within same group
175
+ let parent: AdminNavItem | undefined
176
+ for (const p of entries) {
177
+ if (p === e) continue
178
+ if (p.groupId !== e.groupId) continue
179
+ if (!e.href.startsWith(p.href + '/')) continue
180
+ if (!parent || p.href.length > parent.href.length) parent = p
181
+ }
182
+ if (parent) {
183
+ parent.children = parent.children || []
184
+ parent.children.push(e)
185
+ } else {
186
+ roots.push(e)
187
+ }
188
+ }
189
+
190
+ // Add dynamic user entities to the navigation
191
+ if (userEntities && userEntities.length > 0) {
192
+ const tableIcon = React.createElement(
193
+ 'svg',
194
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },
195
+ React.createElement('rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }),
196
+ React.createElement('path', { d: 'M3 10h18M9 4v16M15 4v16' }),
197
+ )
198
+ // Find the "User Entities" item in the Data designer group (it should be a root item)
199
+ const userEntitiesItem = roots.find(item => item.groupKey === 'entities.nav.group' && item.titleKey === 'entities.nav.userEntities')
200
+ if (userEntitiesItem) {
201
+ const existingChildren = userEntitiesItem.children || []
202
+ const dynamicUserEntities = userEntities.map((entity) => ({
203
+ group: userEntitiesItem.group,
204
+ groupId: userEntitiesItem.groupId,
205
+ groupKey: userEntitiesItem.groupKey,
206
+ groupDefaultName: userEntitiesItem.groupDefaultName,
207
+ title: entity.label,
208
+ defaultTitle: entity.label,
209
+ href: entity.href,
210
+ enabled: true,
211
+ order: 1000, // High order to appear at the end
212
+ priority: 1000,
213
+ icon: tableIcon,
214
+ }))
215
+ // Merge and deduplicate by href to avoid duplicates coming from server or generator
216
+ const merged = [...existingChildren, ...dynamicUserEntities]
217
+ const byHref = new Map<string, AdminNavItem>()
218
+ for (const it of merged) {
219
+ if (!byHref.has(it.href)) byHref.set(it.href, it)
220
+ }
221
+ userEntitiesItem.children = Array.from(byHref.values())
222
+ }
223
+ }
224
+
225
+ // Sorting: group, then priority/order, then title. Apply within children too.
226
+ const sortItems = (arr: AdminNavItem[]) => {
227
+ arr.sort((a, b) => {
228
+ if (a.groupId !== b.groupId) return a.groupId.localeCompare(b.groupId)
229
+ const ap = a.priority ?? a.order ?? 10_000
230
+ const bp = b.priority ?? b.order ?? 10_000
231
+ if (ap !== bp) return ap - bp
232
+ return a.title.localeCompare(b.title)
233
+ })
234
+ for (const it of arr) if (it.children?.length) sortItems(it.children)
235
+ }
236
+ sortItems(roots)
237
+ return roots
238
+ }
@@ -0,0 +1,302 @@
1
+ export type CrudServerFieldErrors = Record<string, string>
2
+
3
+ export type NormalizedCrudServerError = {
4
+ message?: string
5
+ fieldErrors?: CrudServerFieldErrors
6
+ details?: unknown
7
+ status?: number
8
+ raw?: string | null
9
+ [key: string]: unknown
10
+ }
11
+
12
+ const JSON_FIELD_KEYS = ['fieldErrors', 'fields', 'errors', 'data'] as const
13
+ const ISSUE_KEYS = ['details', 'issues', 'errors'] as const
14
+
15
+ function coerceFieldErrors(input: unknown): CrudServerFieldErrors | null {
16
+ if (!input || typeof input !== 'object') return null
17
+ const result: CrudServerFieldErrors = {}
18
+ for (const [rawKey, rawValue] of Object.entries(input as Record<string, unknown>)) {
19
+ const key = typeof rawKey === 'string' && rawKey.trim().length > 0 ? rawKey.trim() : null
20
+ if (!key) continue
21
+ if (rawValue === undefined || rawValue === null) continue
22
+ const message =
23
+ typeof rawValue === 'string'
24
+ ? rawValue
25
+ : typeof (rawValue as any)?.message === 'string'
26
+ ? (rawValue as any).message
27
+ : String(rawValue)
28
+ if (!message) continue
29
+ result[key] = message
30
+ }
31
+ return Object.keys(result).length ? result : null
32
+ }
33
+
34
+ function mapIssueArray(issues: unknown): CrudServerFieldErrors | null {
35
+ if (!Array.isArray(issues)) return null
36
+ const result: CrudServerFieldErrors = {}
37
+ for (const issue of issues) {
38
+ if (!issue || typeof issue !== 'object') continue
39
+ const pathValue: unknown[] = Array.isArray((issue as any).path) ? (issue as any).path : []
40
+ let field: string | null = null
41
+ for (const part of pathValue) {
42
+ if (typeof part === 'string' && part.trim().length > 0) {
43
+ field = part.trim()
44
+ break
45
+ }
46
+ }
47
+ if (!field && typeof (issue as any).field === 'string') {
48
+ const fromField = ((issue as any).field as string).trim()
49
+ field = fromField.length > 0 ? fromField : null
50
+ }
51
+ if (!field && pathValue.length > 0) {
52
+ const joined = pathValue.map((part) => String(part)).join('.')
53
+ if (joined) field = joined
54
+ }
55
+ if (!field) continue
56
+ const message = typeof (issue as any).message === 'string' ? (issue as any).message : null
57
+ if (!message) continue
58
+ result[field] = message
59
+ }
60
+ return Object.keys(result).length ? result : null
61
+ }
62
+
63
+ function tryParseJson(text: string): unknown {
64
+ try {
65
+ return JSON.parse(text)
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ function collectCandidatePayloads(err: unknown): unknown[] {
72
+ const candidates: unknown[] = []
73
+ if (!err) return candidates
74
+ candidates.push(err)
75
+
76
+ if (typeof err === 'string') {
77
+ const parsed = tryParseJson(err)
78
+ if (parsed) candidates.push(parsed)
79
+ } else if (err instanceof Error) {
80
+ if (typeof err.message === 'string' && err.message.trim()) {
81
+ const parsed = tryParseJson(err.message.trim())
82
+ if (parsed) candidates.push(parsed)
83
+ }
84
+ if ((err as any).cause) {
85
+ candidates.push((err as any).cause)
86
+ }
87
+ } else if (typeof err === 'object') {
88
+ const maybeMessage = (err as any)?.message
89
+ if (typeof maybeMessage === 'string') {
90
+ const parsed = tryParseJson(maybeMessage)
91
+ if (parsed) candidates.push(parsed)
92
+ }
93
+ if ((err as any)?.body) candidates.push((err as any).body)
94
+ if ((err as any)?.response) candidates.push((err as any).response)
95
+ if ((err as any)?.data) candidates.push((err as any).data)
96
+ }
97
+
98
+ return candidates
99
+ }
100
+
101
+ export function normalizeCrudServerError(err: unknown): NormalizedCrudServerError {
102
+ let message: string | undefined
103
+ let fieldErrors: CrudServerFieldErrors | undefined
104
+ const processed = new Set<unknown>()
105
+
106
+ const queue = collectCandidatePayloads(err)
107
+ while (queue.length) {
108
+ const current = queue.shift()
109
+ if (!current || processed.has(current)) continue
110
+ processed.add(current)
111
+
112
+ if (typeof current === 'string') {
113
+ if (!message) message = current
114
+ const parsed = tryParseJson(current)
115
+ if (parsed) queue.push(parsed)
116
+ continue
117
+ }
118
+
119
+ if (current instanceof Response) {
120
+ const body = (current as any)?._bodyInit
121
+ if (body) queue.push(body)
122
+ continue
123
+ }
124
+
125
+ if (typeof current !== 'object') continue
126
+
127
+ const candidateMessage =
128
+ typeof (current as any).error === 'string'
129
+ ? (current as any).error
130
+ : typeof (current as any).message === 'string'
131
+ ? (current as any).message
132
+ : undefined
133
+ if (candidateMessage && !message) message = candidateMessage
134
+
135
+ for (const key of JSON_FIELD_KEYS) {
136
+ const value = (current as any)[key]
137
+ if (value && typeof value === 'object') {
138
+ const mapped = coerceFieldErrors(value)
139
+ if (mapped) {
140
+ fieldErrors = { ...(fieldErrors || {}), ...mapped }
141
+ }
142
+ }
143
+ }
144
+
145
+ for (const key of ISSUE_KEYS) {
146
+ const value = (current as any)[key]
147
+ const mapped = mapIssueArray(value)
148
+ if (mapped) {
149
+ fieldErrors = { ...(fieldErrors || {}), ...mapped }
150
+ }
151
+ }
152
+
153
+ const nestedKeys = ['body', 'response', 'data', 'details']
154
+ for (const nestedKey of nestedKeys) {
155
+ const nested = (current as any)[nestedKey]
156
+ if (nested && !processed.has(nested)) queue.push(nested)
157
+ }
158
+ }
159
+
160
+ if (!message && fieldErrors && Object.keys(fieldErrors).length === 1) {
161
+ const [, firstMessage] = Object.entries(fieldErrors)[0]
162
+ message = firstMessage
163
+ }
164
+
165
+ if (!message && err instanceof Error && err.message) {
166
+ message = err.message
167
+ } else if (!message && typeof err === 'string') {
168
+ message = err
169
+ }
170
+
171
+ return { message, fieldErrors }
172
+ }
173
+
174
+ export type FieldNameMapperOptions = {
175
+ customEntity?: boolean
176
+ }
177
+
178
+ export function mapServerFieldNameToFormId(field: string, options?: FieldNameMapperOptions): string {
179
+ const trimmed = field.trim()
180
+ const customEntity = !!options?.customEntity
181
+ if (customEntity) {
182
+ if (trimmed.startsWith('cf_')) return trimmed.slice(3)
183
+ if (trimmed.startsWith('cf:')) return trimmed.slice(3)
184
+ return trimmed
185
+ }
186
+ if (trimmed.startsWith('cf_')) return trimmed
187
+ if (trimmed.startsWith('cf:')) return `cf_${trimmed.slice(3)}`
188
+ return trimmed
189
+ }
190
+
191
+ export function mapCrudServerErrorToFormErrors(
192
+ err: unknown,
193
+ options?: FieldNameMapperOptions,
194
+ ): { message?: string; fieldErrors?: CrudServerFieldErrors } {
195
+ const normalized = normalizeCrudServerError(err)
196
+ const fieldErrors = normalized.fieldErrors
197
+ if (!fieldErrors) return { message: normalized.message }
198
+
199
+ const mapped: CrudServerFieldErrors = {}
200
+ for (const [key, value] of Object.entries(fieldErrors)) {
201
+ const formId = mapServerFieldNameToFormId(key, options)
202
+ if (!formId) continue
203
+ mapped[formId] = value
204
+ }
205
+
206
+ let message = normalized.message
207
+ const firstEntry = Object.entries(mapped)[0]
208
+ if (
209
+ firstEntry &&
210
+ (!message || (typeof message === 'string' && message.trim().toLowerCase() === 'invalid input'))
211
+ ) {
212
+ const [, fieldMessage] = firstEntry
213
+ if (typeof fieldMessage === 'string' && fieldMessage.trim().length) {
214
+ message = fieldMessage
215
+ }
216
+ }
217
+
218
+ return {
219
+ message,
220
+ fieldErrors: mapped,
221
+ }
222
+ }
223
+
224
+ export function parseServerMessage(input: string): string {
225
+ const trimmed = input.trim()
226
+ if (!trimmed) return trimmed
227
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
228
+ try {
229
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>
230
+ const text =
231
+ typeof parsed?.error === 'string' && parsed.error.trim()
232
+ ? parsed.error.trim()
233
+ : typeof parsed?.message === 'string' && parsed.message.trim()
234
+ ? parsed.message.trim()
235
+ : null
236
+ if (text) return text
237
+ } catch {
238
+ // ignore JSON parse failure, fall through to trimmed string
239
+ }
240
+ }
241
+ return trimmed
242
+ }
243
+
244
+ export async function raiseCrudError(res: Response, fallbackMessage?: string): Promise<never> {
245
+ let raw: string | null = null
246
+ try {
247
+ raw = await res.text()
248
+ } catch {
249
+ raw = null
250
+ }
251
+
252
+ const trimmed = raw && raw.trim() ? raw.trim() : null
253
+ const parsed = trimmed ? tryParseJson(trimmed) : null
254
+
255
+ if (parsed && typeof parsed === 'object') {
256
+ const data = parsed as Record<string, unknown>
257
+ const rawMessage =
258
+ typeof data.error === 'string' && data.error.trim()
259
+ ? data.error.trim()
260
+ : typeof data.message === 'string' && data.message.trim()
261
+ ? data.message.trim()
262
+ : fallbackMessage ?? `Request failed (${res.status})`
263
+ const message = parseServerMessage(rawMessage)
264
+ throw {
265
+ ...data,
266
+ status: res.status,
267
+ message,
268
+ raw: trimmed ?? null,
269
+ }
270
+ }
271
+
272
+ const message = parseServerMessage(fallbackMessage ?? `Request failed (${res.status})`)
273
+ throw { message, status: res.status, raw: trimmed ?? null }
274
+ }
275
+
276
+ export type CrudFormError = Error & {
277
+ status?: number
278
+ fieldErrors?: CrudServerFieldErrors
279
+ details?: unknown
280
+ }
281
+
282
+ export function createCrudFormError(
283
+ message: string,
284
+ fieldErrors?: CrudServerFieldErrors,
285
+ extras?: Partial<Pick<CrudFormError, 'status' | 'details'>>,
286
+ ): CrudFormError {
287
+ const error = new Error(message) as CrudFormError
288
+ if (fieldErrors && Object.keys(fieldErrors).length) error.fieldErrors = fieldErrors
289
+ if (extras?.status !== undefined) error.status = extras.status
290
+ if (extras?.details !== undefined) error.details = extras.details
291
+ return error
292
+ }
293
+
294
+ export async function readJsonSafe<T>(res: Response, fallback: T | null = null): Promise<T | null> {
295
+ try {
296
+ const text = await res.text()
297
+ if (!text) return fallback
298
+ return JSON.parse(text) as T
299
+ } catch {
300
+ return fallback
301
+ }
302
+ }
@@ -0,0 +1,29 @@
1
+ "use client"
2
+ import Link from 'next/link'
3
+ import { usePathname } from 'next/navigation'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { LanguageSwitcher } from './LanguageSwitcher'
6
+
7
+ export function AuthFooter() {
8
+ const pathname = usePathname()
9
+ const t = useT()
10
+ const shouldShow =
11
+ pathname === '/login' ||
12
+ (typeof pathname === 'string' && pathname.startsWith('/onboarding'))
13
+ if (!shouldShow) return null
14
+ return (
15
+ <footer className="w-full border-t bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/50">
16
+ <div className="max-w-screen-lg mx-auto px-4 py-3 flex flex-wrap items-center justify-end gap-4">
17
+ <nav className="flex items-center gap-3 text-xs text-muted-foreground">
18
+ <Link href="/terms" className="transition hover:text-foreground">
19
+ {t('common.terms')}
20
+ </Link>
21
+ <Link href="/privacy" className="transition hover:text-foreground">
22
+ {t('common.privacy')}
23
+ </Link>
24
+ </nav>
25
+ <LanguageSwitcher />
26
+ </div>
27
+ </footer>
28
+ )
29
+ }
@@ -0,0 +1,66 @@
1
+ "use client"
2
+ import { useId, useTransition } from 'react'
3
+ import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
4
+ import { useRouter } from 'next/navigation'
5
+ import { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
6
+
7
+ export function LanguageSwitcher() {
8
+ const current = useLocale()
9
+ const t = useT()
10
+ const router = useRouter()
11
+ const [pending, startTransition] = useTransition()
12
+ const selectId = useId()
13
+
14
+ const languageLabels: Record<Locale, string> = {
15
+ en: t('common.languages.english', 'English'),
16
+ pl: t('common.languages.polish', 'Polski'),
17
+ es: t('common.languages.spanish', 'Español'),
18
+ de: t('common.languages.german', 'Deutsch'),
19
+ }
20
+
21
+ async function setLocale(locale: Locale) {
22
+ if (locale === current) return
23
+ try {
24
+ const res = await fetch('/api/auth/locale', {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json' },
27
+ body: JSON.stringify({ locale }),
28
+ })
29
+ if (!res.ok) return
30
+ startTransition(() => router.refresh())
31
+ try {
32
+ window.dispatchEvent(new Event('om:refresh-sidebar'))
33
+ } catch {
34
+ // Ignore if window is unavailable
35
+ }
36
+ } catch {
37
+ // Ignore network errors; UX fallback keeps previous locale
38
+ }
39
+ }
40
+
41
+ return (
42
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
43
+ <label htmlFor={selectId}>{t('common.language')}</label>
44
+ <div className="relative">
45
+ <select
46
+ id={selectId}
47
+ className="appearance-none rounded-md border bg-background px-3 py-1 pr-8 text-xs focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:opacity-60"
48
+ value={current}
49
+ onChange={(event) => setLocale(event.target.value as Locale)}
50
+ disabled={pending}
51
+ >
52
+ {locales.map((locale) => (
53
+ <option key={locale} value={locale}>
54
+ {languageLabels[locale]}
55
+ </option>
56
+ ))}
57
+ </select>
58
+ <span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground">
59
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
60
+ <path d="M6 9l6 6 6-6" />
61
+ </svg>
62
+ </span>
63
+ </div>
64
+ </div>
65
+ )
66
+ }
@@ -0,0 +1,13 @@
1
+ import * as React from 'react'
2
+ import { FlashMessages } from '../backend/FlashMessages'
3
+
4
+ export function FrontendLayout({ header, footer, children }: { header?: React.ReactNode; footer?: React.ReactNode; children: React.ReactNode }) {
5
+ return (
6
+ <div className="min-h-svh flex flex-col">
7
+ <FlashMessages />
8
+ {header ? <div className="border-b bg-background/60">{header}</div> : null}
9
+ <div className="flex-1 min-h-0">{children}</div>
10
+ {footer ? <div className="border-t bg-background/60">{footer}</div> : null}
11
+ </div>
12
+ )
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export * from './theme/ThemeProvider'
2
+ export * from './theme/ThemeToggle'
3
+ export * from './theme/QueryProvider'
4
+ export * from './backend/AppShell'
5
+ export * from './backend/Page'
6
+ export * from './backend/DataTable'
7
+ export * from './backend/FilterBar'
8
+ export * from './backend/ValueIcons'
9
+ export * from './backend/ConfirmDialog'
10
+ export * from './backend/UserMenu'
11
+ export * from './backend/RowActions'
12
+ export * from './backend/utils/nav'
13
+ export * from './backend/CrudForm'
14
+ export * from './backend/JsonBuilder'
15
+ export * from './backend/detail'
16
+ export * from './backend/schedule'
17
+
18
+ export * from './backend/inputs'
19
+ export * from './backend/ContextHelp'
20
+ export * from './backend/dashboard'
21
+ export * from './frontend/Layout'
22
+ export * from './frontend/AuthFooter'
23
+ export * from './frontend/LanguageSwitcher'
24
+ export * from './primitives/button'
25
+ export * from './primitives/label'
26
+ export * from './primitives/separator'
27
+ export * from './primitives/spinner'
28
+ export * from './primitives/tabs'
29
+ export * from './primitives/DataLoader'
30
+ export * from './primitives/table'
31
+ export * from './primitives/ErrorNotice'
32
+ export * from './primitives/dialog'