@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,1275 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import type { PluggableList } from 'unified'
6
+ import type { AppearanceSelectorLabels } from '@open-mercato/core/modules/dictionaries/components/AppearanceSelector'
7
+ import { AppearanceDialog } from '@open-mercato/core/modules/customers/components/detail/AppearanceDialog'
8
+ import type { IconOption } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
9
+ import { ArrowUpRightSquare, FileCode, Loader2, Palette, Pencil, Plus, Trash2 } from 'lucide-react'
10
+ import { Button } from '@open-mercato/ui/primitives/button'
11
+ import { flash } from '../FlashMessages'
12
+ import { SwitchableMarkdownInput } from '../inputs/SwitchableMarkdownInput'
13
+ import { ErrorMessage } from './ErrorMessage'
14
+ import { LoadingMessage } from './LoadingMessage'
15
+ import { TabEmptyState } from './TabEmptyState'
16
+
17
+ type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
18
+
19
+ export type SectionAction = {
20
+ label: React.ReactNode
21
+ onClick: () => void
22
+ disabled?: boolean
23
+ icon?: React.ReactNode
24
+ }
25
+
26
+ export type TabEmptyStateConfig = {
27
+ title: string
28
+ actionLabel: string
29
+ description?: string
30
+ }
31
+
32
+ export type CommentSummary = {
33
+ id: string
34
+ body: string
35
+ createdAt: string
36
+ authorUserId?: string | null
37
+ authorName?: string | null
38
+ authorEmail?: string | null
39
+ dealId?: string | null
40
+ dealTitle?: string | null
41
+ appearanceIcon?: string | null
42
+ appearanceColor?: string | null
43
+ }
44
+
45
+ export type NotesCreatePayload = {
46
+ entityId: string
47
+ body: string
48
+ appearanceIcon: string | null
49
+ appearanceColor: string | null
50
+ dealId?: string | null
51
+ }
52
+
53
+ export type NotesUpdatePayload = {
54
+ body?: string
55
+ appearanceIcon?: string | null
56
+ appearanceColor?: string | null
57
+ }
58
+
59
+ export type NotesDataAdapter<C = unknown> = {
60
+ list: (params: { entityId: string | null; dealId: string | null; context?: C }) => Promise<CommentSummary[]>
61
+ create: (params: NotesCreatePayload & { context?: C }) => Promise<Partial<CommentSummary> | void>
62
+ update: (params: { id: string; patch: NotesUpdatePayload; context?: C }) => Promise<void>
63
+ delete: (params: { id: string; context?: C }) => Promise<void>
64
+ }
65
+
66
+ type RenderIconFn = (icon: string, className?: string) => React.ReactNode
67
+ type RenderColorFn = (color: string, className?: string) => React.ReactNode
68
+
69
+ type MarkdownPreviewProps = { children: string; className?: string; remarkPlugins?: PluggableList }
70
+
71
+ const isTestEnv = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
72
+
73
+ const MarkdownPreviewComponent: React.ComponentType<MarkdownPreviewProps> = isTestEnv
74
+ ? ({ children, className }) => <div className={className}>{children}</div>
75
+ : (dynamic(() => import('react-markdown').then((mod) => mod.default as React.ComponentType<MarkdownPreviewProps>), {
76
+ ssr: false,
77
+ loading: () => null,
78
+ }) as unknown as React.ComponentType<MarkdownPreviewProps>)
79
+
80
+ let markdownPluginsPromise: Promise<PluggableList> | null = null
81
+
82
+ async function loadMarkdownPlugins(): Promise<PluggableList> {
83
+ if (isTestEnv) return []
84
+ if (!markdownPluginsPromise) {
85
+ markdownPluginsPromise = import('remark-gfm')
86
+ .then((mod) => [mod.default ?? mod] as PluggableList)
87
+ .catch(() => [])
88
+ }
89
+ return markdownPluginsPromise
90
+ }
91
+
92
+ function generateTempId() {
93
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
94
+ return `tmp_${Math.random().toString(36).slice(2)}`
95
+ }
96
+
97
+ function formatDateTime(value?: string | null): string | null {
98
+ if (!value) return null
99
+ const date = new Date(value)
100
+ if (Number.isNaN(date.getTime())) return null
101
+ return date.toLocaleString()
102
+ }
103
+
104
+ function formatRelativeTime(value?: string | null): string | null {
105
+ if (!value) return null
106
+ const date = new Date(value)
107
+ if (Number.isNaN(date.getTime())) return null
108
+ const now = Date.now()
109
+ const diffSeconds = (date.getTime() - now) / 1000
110
+ const absSeconds = Math.abs(diffSeconds)
111
+ const rtf =
112
+ typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function'
113
+ ? new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
114
+ : null
115
+ const format = (unit: Intl.RelativeTimeFormatUnit, divisor: number) => {
116
+ const valueToFormat = Math.round(diffSeconds / divisor)
117
+ if (rtf) return rtf.format(valueToFormat, unit)
118
+ const suffix = valueToFormat <= 0 ? 'ago' : 'from now'
119
+ const magnitude = Math.abs(valueToFormat)
120
+ return `${magnitude} ${unit}${magnitude === 1 ? '' : 's'} ${suffix}`
121
+ }
122
+ if (absSeconds < 45) return format('second', 1)
123
+ if (absSeconds < 45 * 60) return format('minute', 60)
124
+ if (absSeconds < 24 * 60 * 60) return format('hour', 60 * 60)
125
+ if (absSeconds < 7 * 24 * 60 * 60) return format('day', 24 * 60 * 60)
126
+ if (absSeconds < 30 * 24 * 60 * 60) return format('week', 7 * 24 * 60 * 60)
127
+ if (absSeconds < 365 * 24 * 60 * 60) return format('month', 30 * 24 * 60 * 60)
128
+ return format('year', 365 * 24 * 60 * 60)
129
+ }
130
+
131
+ type TimelineItemHeaderProps = {
132
+ title: React.ReactNode
133
+ subtitle?: React.ReactNode
134
+ timestamp?: string | Date | null
135
+ fallbackTimestampLabel?: React.ReactNode
136
+ icon?: string | null
137
+ color?: string | null
138
+ iconSize?: 'sm' | 'md'
139
+ className?: string
140
+ renderIcon?: RenderIconFn
141
+ renderColor?: RenderColorFn
142
+ }
143
+
144
+ function TimelineItemHeader({
145
+ title,
146
+ subtitle,
147
+ timestamp,
148
+ fallbackTimestampLabel,
149
+ icon,
150
+ color,
151
+ iconSize = 'md',
152
+ className,
153
+ renderIcon,
154
+ renderColor,
155
+ }: TimelineItemHeaderProps) {
156
+ const wrapperSize = iconSize === 'sm' ? 'h-6 w-6' : 'h-8 w-8'
157
+ const iconSizeClass = iconSize === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'
158
+ const resolvedTimestamp = React.useMemo(() => {
159
+ if (subtitle) return subtitle
160
+ if (!timestamp) return fallbackTimestampLabel ?? null
161
+ const value = typeof timestamp === 'string' ? timestamp : timestamp.toISOString()
162
+ const date = new Date(value)
163
+ if (Number.isNaN(date.getTime())) return fallbackTimestampLabel ?? null
164
+ const now = Date.now()
165
+ const diff = Math.abs(now - date.getTime())
166
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
167
+ const relativeLabel = diff <= THIRTY_DAYS_MS ? formatRelativeTime(value) : null
168
+ const absoluteLabel = formatDateTime(value)
169
+ if (relativeLabel) {
170
+ return (
171
+ <span title={absoluteLabel ?? undefined}>
172
+ {relativeLabel}
173
+ </span>
174
+ )
175
+ }
176
+ return absoluteLabel ?? fallbackTimestampLabel ?? null
177
+ }, [fallbackTimestampLabel, subtitle, timestamp])
178
+
179
+ return (
180
+ <div className={['flex items-start gap-3', className].filter(Boolean).join(' ')}>
181
+ {icon && renderIcon ? (
182
+ <span className={['inline-flex items-center justify-center rounded border border-border bg-muted/40', wrapperSize].join(' ')}>
183
+ {renderIcon(icon, iconSizeClass)}
184
+ </span>
185
+ ) : null}
186
+ <div className="space-y-1">
187
+ <div className="flex flex-wrap items-center gap-2">
188
+ <span className="text-sm font-semibold text-foreground">{title}</span>
189
+ {color && renderColor ? renderColor(color, 'h-3 w-3 rounded-full border border-border') : null}
190
+ </div>
191
+ {resolvedTimestamp ? <div className="text-xs text-muted-foreground">{resolvedTimestamp}</div> : null}
192
+ </div>
193
+ </div>
194
+ )
195
+ }
196
+
197
+ export type NotesSectionProps<C = unknown> = {
198
+ entityId: string | null
199
+ dealId?: string | null
200
+ emptyLabel: string
201
+ viewerUserId: string | null
202
+ viewerName?: string | null
203
+ viewerEmail?: string | null
204
+ addActionLabel: string
205
+ emptyState: TabEmptyStateConfig
206
+ onActionChange?: (action: SectionAction | null) => void
207
+ translator?: Translator
208
+ labelPrefix?: string
209
+ inlineLabelPrefix?: string
210
+ onLoadingChange?: (isLoading: boolean) => void
211
+ dealOptions?: Array<{ id: string; label: string }>
212
+ entityOptions?: Array<{ id: string; label: string }>
213
+ dataAdapter: NotesDataAdapter<C>
214
+ dataContext?: C
215
+ renderIcon?: RenderIconFn
216
+ renderColor?: RenderColorFn
217
+ iconSuggestions?: IconOption[]
218
+ readMarkdownPreference?: () => boolean | null
219
+ writeMarkdownPreference?: (value: boolean) => void
220
+ disableMarkdown?: boolean
221
+ }
222
+
223
+ export function sanitizeHexColor(value: string | null): string | null {
224
+ if (!value) return null
225
+ const trimmed = value.trim()
226
+ return /^#([0-9a-f]{6})$/i.test(trimmed) ? trimmed.toLowerCase() : null
227
+ }
228
+
229
+ export function mapCommentSummary(input: unknown): CommentSummary {
230
+ const data = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
231
+ const id = typeof data.id === 'string' ? data.id : generateTempId()
232
+ const body = typeof data.body === 'string' ? data.body : ''
233
+ const createdAt =
234
+ typeof data.createdAt === 'string'
235
+ ? data.createdAt
236
+ : typeof data.created_at === 'string'
237
+ ? data.created_at
238
+ : new Date().toISOString()
239
+ const authorUserId =
240
+ typeof data.authorUserId === 'string'
241
+ ? data.authorUserId
242
+ : typeof data.author_user_id === 'string'
243
+ ? data.author_user_id
244
+ : null
245
+ const authorName =
246
+ typeof data.authorName === 'string'
247
+ ? data.authorName
248
+ : typeof data.author_name === 'string'
249
+ ? data.author_name
250
+ : null
251
+ const authorEmail =
252
+ typeof data.authorEmail === 'string'
253
+ ? data.authorEmail
254
+ : typeof data.author_email === 'string'
255
+ ? data.author_email
256
+ : null
257
+ const dealId =
258
+ typeof data.dealId === 'string'
259
+ ? data.dealId
260
+ : typeof data.deal_id === 'string'
261
+ ? data.deal_id
262
+ : null
263
+ const dealTitle =
264
+ typeof data.dealTitle === 'string'
265
+ ? data.dealTitle
266
+ : typeof data.deal_title === 'string'
267
+ ? data.deal_title
268
+ : null
269
+ const appearanceIcon =
270
+ typeof data.appearanceIcon === 'string'
271
+ ? data.appearanceIcon
272
+ : typeof data.appearance_icon === 'string'
273
+ ? data.appearance_icon
274
+ : null
275
+ const appearanceColor =
276
+ typeof data.appearanceColor === 'string'
277
+ ? data.appearanceColor
278
+ : typeof data.appearance_color === 'string'
279
+ ? data.appearance_color
280
+ : null
281
+ return {
282
+ id,
283
+ body,
284
+ createdAt,
285
+ authorUserId,
286
+ authorName,
287
+ authorEmail,
288
+ dealId,
289
+ dealTitle,
290
+ appearanceIcon,
291
+ appearanceColor,
292
+ }
293
+ }
294
+
295
+ export function NotesSection<C = unknown>({
296
+ entityId,
297
+ dealId,
298
+ emptyLabel,
299
+ viewerUserId,
300
+ viewerName,
301
+ viewerEmail,
302
+ addActionLabel,
303
+ emptyState,
304
+ onActionChange,
305
+ translator,
306
+ labelPrefix = 'customers.people.detail.notes',
307
+ inlineLabelPrefix = 'customers.people.detail.inline',
308
+ onLoadingChange,
309
+ dealOptions,
310
+ entityOptions,
311
+ dataAdapter,
312
+ dataContext,
313
+ renderIcon,
314
+ renderColor,
315
+ iconSuggestions,
316
+ readMarkdownPreference,
317
+ writeMarkdownPreference,
318
+ disableMarkdown,
319
+ }: NotesSectionProps<C>) {
320
+ const t = React.useMemo<Translator>(() => translator ?? ((key, fallback) => fallback ?? key), [translator])
321
+ const label = React.useCallback(
322
+ (suffix: string, fallback?: string, params?: Record<string, string | number>) =>
323
+ t(`${labelPrefix}.${suffix}`, fallback, params),
324
+ [labelPrefix, t],
325
+ )
326
+ const inlineLabel = React.useCallback(
327
+ (suffix: string, fallback?: string, params?: Record<string, string | number>) =>
328
+ t(`${inlineLabelPrefix}.${suffix}`, fallback, params),
329
+ [inlineLabelPrefix, t],
330
+ )
331
+ const [markdownPlugins, setMarkdownPlugins] = React.useState<PluggableList>([])
332
+ React.useEffect(() => {
333
+ if (isTestEnv) return
334
+ let mounted = true
335
+ void loadMarkdownPlugins().then((plugins) => {
336
+ if (!mounted) return
337
+ setMarkdownPlugins(plugins)
338
+ })
339
+ return () => {
340
+ mounted = false
341
+ }
342
+ }, [])
343
+
344
+ const normalizedDealOptions = React.useMemo(() => {
345
+ if (!Array.isArray(dealOptions)) return []
346
+ const seen = new Set<string>()
347
+ return dealOptions
348
+ .map((option) => {
349
+ if (!option || typeof option !== 'object') return null
350
+ const id = typeof option.id === 'string' ? option.id.trim() : ''
351
+ if (!id || seen.has(id)) return null
352
+ const label =
353
+ typeof option.label === 'string' && option.label.trim().length
354
+ ? option.label.trim()
355
+ : id
356
+ seen.add(id)
357
+ return { id, label }
358
+ })
359
+ .filter((option): option is { id: string; label: string } => !!option)
360
+ }, [dealOptions])
361
+
362
+ const dealLabelMap = React.useMemo(() => {
363
+ const map = new Map<string, string>()
364
+ normalizedDealOptions.forEach((option) => {
365
+ map.set(option.id, option.label)
366
+ })
367
+ return map
368
+ }, [normalizedDealOptions])
369
+
370
+ const normalizedEntityOptions = React.useMemo(() => {
371
+ if (!Array.isArray(entityOptions)) return []
372
+ const seen = new Set<string>()
373
+ return entityOptions
374
+ .map((option) => {
375
+ if (!option || typeof option !== 'object') return null
376
+ const id = typeof option.id === 'string' ? option.id.trim() : ''
377
+ if (!id || seen.has(id)) return null
378
+ const label =
379
+ typeof option.label === 'string' && option.label.trim().length
380
+ ? option.label.trim()
381
+ : id
382
+ seen.add(id)
383
+ return { id, label }
384
+ })
385
+ .filter((option): option is { id: string; label: string } => !!option)
386
+ }, [entityOptions])
387
+
388
+ const [selectedDealId, setSelectedDealId] = React.useState<string>(() => {
389
+ const initial = typeof dealId === 'string' ? dealId.trim() : ''
390
+ return initial
391
+ })
392
+ React.useEffect(() => {
393
+ const initial = typeof dealId === 'string' ? dealId.trim() : ''
394
+ if (initial !== selectedDealId) {
395
+ setSelectedDealId(initial)
396
+ }
397
+ }, [dealId, selectedDealId])
398
+
399
+ const [selectedEntityId, setSelectedEntityId] = React.useState<string>(() => {
400
+ if (normalizedEntityOptions.length) return normalizedEntityOptions[0].id
401
+ return typeof entityId === 'string' ? entityId : ''
402
+ })
403
+ React.useEffect(() => {
404
+ if (normalizedEntityOptions.length) {
405
+ if (!normalizedEntityOptions.some((option) => option.id === selectedEntityId)) {
406
+ setSelectedEntityId(normalizedEntityOptions[0].id)
407
+ }
408
+ } else {
409
+ const initial = typeof entityId === 'string' ? entityId : ''
410
+ if (initial !== selectedEntityId) {
411
+ setSelectedEntityId(initial)
412
+ }
413
+ }
414
+ }, [entityId, normalizedEntityOptions, selectedEntityId])
415
+
416
+ const resolvedEntityId = React.useMemo(() => {
417
+ if (normalizedEntityOptions.length) return selectedEntityId
418
+ return typeof entityId === 'string' ? entityId : ''
419
+ }, [entityId, normalizedEntityOptions, selectedEntityId])
420
+
421
+ const resolvedDealId = React.useMemo(() => {
422
+ const trimmed = typeof selectedDealId === 'string' ? selectedDealId.trim() : ''
423
+ return trimmed
424
+ }, [selectedDealId])
425
+
426
+ const hasEntity = resolvedEntityId.length > 0
427
+
428
+ const [notes, setNotes] = React.useState<CommentSummary[]>([])
429
+ const [isLoading, setIsLoading] = React.useState<boolean>(() => Boolean(entityId || dealId))
430
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
431
+ const [loadError, setLoadError] = React.useState<string | null>(null)
432
+ const pendingCounterRef = React.useRef(0)
433
+
434
+ const pushLoading = React.useCallback(() => {
435
+ pendingCounterRef.current += 1
436
+ if (pendingCounterRef.current === 1) {
437
+ onLoadingChange?.(true)
438
+ }
439
+ }, [onLoadingChange])
440
+
441
+ const popLoading = React.useCallback(() => {
442
+ pendingCounterRef.current = Math.max(0, pendingCounterRef.current - 1)
443
+ if (pendingCounterRef.current === 0) {
444
+ onLoadingChange?.(false)
445
+ }
446
+ }, [onLoadingChange])
447
+
448
+ const [composerOpen, setComposerOpen] = React.useState(false)
449
+ const [draftBody, setDraftBody] = React.useState('')
450
+ const [draftIcon, setDraftIcon] = React.useState<string | null>(null)
451
+ const [draftColor, setDraftColor] = React.useState<string | null>(null)
452
+ const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(false)
453
+ const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
454
+ const formRef = React.useRef<HTMLFormElement | null>(null)
455
+ const focusComposer = React.useCallback(() => {
456
+ if (!hasEntity) return
457
+ setComposerOpen(true)
458
+ window.requestAnimationFrame(() => {
459
+ if (isMarkdownEnabled) {
460
+ const markdownTextarea = formRef.current?.querySelector('textarea')
461
+ if (markdownTextarea instanceof HTMLTextAreaElement) {
462
+ markdownTextarea.focus()
463
+ markdownTextarea.scrollIntoView({ behavior: 'smooth', block: 'center' })
464
+ return
465
+ }
466
+ }
467
+ const element = textareaRef.current
468
+ if (!element) return
469
+ element.focus()
470
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' })
471
+ })
472
+ }, [formRef, hasEntity, isMarkdownEnabled])
473
+ const [appearanceDialogState, setAppearanceDialogState] = React.useState<
474
+ | { mode: 'create'; icon: string | null; color: string | null }
475
+ | { mode: 'edit'; noteId: string; icon: string | null; color: string | null }
476
+ | null
477
+ >(null)
478
+ const [appearanceDialogSaving, setAppearanceDialogSaving] = React.useState(false)
479
+ const [appearanceDialogError, setAppearanceDialogError] = React.useState<string | null>(null)
480
+ const [contentEditor, setContentEditor] = React.useState<{ id: string; value: string }>({ id: '', value: '' })
481
+ const [contentSavingId, setContentSavingId] = React.useState<string | null>(null)
482
+ const [contentError, setContentError] = React.useState<string | null>(null)
483
+ const contentTextareaRef = React.useRef<HTMLTextAreaElement | null>(null)
484
+ const [visibleCount, setVisibleCount] = React.useState(0)
485
+ const [deletingNoteId, setDeletingNoteId] = React.useState<string | null>(null)
486
+
487
+ React.useEffect(() => {
488
+ const queryEntityId = typeof entityId === 'string' ? entityId : ''
489
+ const queryDealId = typeof dealId === 'string' ? dealId : ''
490
+ if (!queryEntityId && !queryDealId) {
491
+ setNotes([])
492
+ setLoadError(null)
493
+ setIsLoading(false)
494
+ return
495
+ }
496
+ let cancelled = false
497
+ setIsLoading(true)
498
+ setLoadError(null)
499
+ pushLoading()
500
+ async function loadNotes() {
501
+ try {
502
+ const mapped = await dataAdapter.list({
503
+ entityId: queryEntityId || null,
504
+ dealId: queryDealId || null,
505
+ context: dataContext,
506
+ })
507
+ if (cancelled) return
508
+ setNotes(mapped)
509
+ } catch (err) {
510
+ if (cancelled) return
511
+ const message =
512
+ err instanceof Error ? err.message : label('loadError', 'Failed to load notes.')
513
+ setNotes([])
514
+ setLoadError(message)
515
+ flash(message, 'error')
516
+ } finally {
517
+ if (!cancelled) setIsLoading(false)
518
+ popLoading()
519
+ }
520
+ }
521
+ loadNotes().catch(() => {})
522
+ return () => {
523
+ cancelled = true
524
+ }
525
+ }, [dataAdapter, dataContext, dealId, entityId, popLoading, pushLoading, t])
526
+
527
+ const youLabel = label('you', 'You')
528
+ const viewerLabel = React.useMemo(() => viewerName ?? viewerEmail ?? null, [viewerEmail, viewerName])
529
+
530
+ const handleMarkdownToggle = React.useCallback(() => {
531
+ setIsMarkdownEnabled((prev) => {
532
+ const next = !prev
533
+ if (writeMarkdownPreference) {
534
+ writeMarkdownPreference(next)
535
+ }
536
+ return next
537
+ })
538
+ }, [writeMarkdownPreference])
539
+
540
+ React.useEffect(() => {
541
+ if (!onActionChange) return
542
+ if (!notes.length) {
543
+ onActionChange(null)
544
+ return
545
+ }
546
+ onActionChange({
547
+ label: addActionLabel,
548
+ onClick: focusComposer,
549
+ disabled: isSubmitting || isLoading || !hasEntity,
550
+ icon: <Plus className="mr-2 h-4 w-4" />,
551
+ })
552
+ return () => onActionChange(null)
553
+ }, [onActionChange, addActionLabel, focusComposer, hasEntity, isLoading, isSubmitting, notes.length])
554
+
555
+ const adjustTextareaSize = React.useCallback((element: HTMLTextAreaElement | null) => {
556
+ if (!element) return
557
+ element.style.height = 'auto'
558
+ element.style.height = `${element.scrollHeight}px`
559
+ }, [])
560
+
561
+ React.useEffect(() => {
562
+ adjustTextareaSize(textareaRef.current)
563
+ }, [adjustTextareaSize, draftBody, isMarkdownEnabled, composerOpen])
564
+
565
+ React.useEffect(() => {
566
+ const preference = readMarkdownPreference ? readMarkdownPreference() : null
567
+ if (preference !== null) {
568
+ setIsMarkdownEnabled(preference)
569
+ }
570
+ }, [readMarkdownPreference])
571
+
572
+ React.useEffect(() => {
573
+ if (!notes.length) {
574
+ setVisibleCount(0)
575
+ return
576
+ }
577
+ const baseline = Math.min(5, notes.length)
578
+ setVisibleCount((prev) => {
579
+ if (prev >= notes.length) return prev
580
+ return Math.min(Math.max(prev, baseline), notes.length)
581
+ })
582
+ }, [notes.length])
583
+
584
+ React.useEffect(() => {
585
+ if (hasEntity) return
586
+ setComposerOpen(false)
587
+ setDraftBody('')
588
+ setDraftIcon(null)
589
+ setDraftColor(null)
590
+ }, [hasEntity])
591
+
592
+ const visibleNotes = React.useMemo(() => notes.slice(0, visibleCount), [notes, visibleCount])
593
+ const hasVisibleNotes = React.useMemo(() => visibleCount > 0, [visibleCount])
594
+
595
+ const loadMoreLabel = label('loadMore')
596
+
597
+ const handleCreateNote = React.useCallback(
598
+ async (input: { body: string; appearanceIcon: string | null; appearanceColor: string | null }) => {
599
+ if (!hasEntity || !resolvedEntityId) {
600
+ flash(label('entityMissing', 'Unable to determine current person.'), 'error')
601
+ return false
602
+ }
603
+ const body = input.body.trim()
604
+ if (!body) {
605
+ focusComposer()
606
+ return false
607
+ }
608
+ const icon = input.appearanceIcon && input.appearanceIcon.trim().length ? input.appearanceIcon.trim() : null
609
+ const color = sanitizeHexColor(input.appearanceColor)
610
+ const targetDealId = resolvedDealId.length ? resolvedDealId : null
611
+ const dealLabel = targetDealId ? dealLabelMap.get(targetDealId) ?? null : null
612
+ setIsSubmitting(true)
613
+ pushLoading()
614
+ try {
615
+ const responseBody =
616
+ (await dataAdapter.create({
617
+ entityId: resolvedEntityId,
618
+ body,
619
+ appearanceIcon: icon,
620
+ appearanceColor: color,
621
+ dealId: targetDealId,
622
+ context: dataContext,
623
+ })) ?? {}
624
+ setNotes((prev) => {
625
+ const viewerId = viewerUserId ?? null
626
+ const resolvedAuthorId =
627
+ typeof responseBody?.authorUserId === 'string' ? responseBody.authorUserId : viewerId ?? null
628
+ const resolvedAuthorName = (() => {
629
+ if (resolvedAuthorId && viewerId && resolvedAuthorId === viewerId) {
630
+ return youLabel
631
+ }
632
+ return typeof responseBody?.authorName === 'string' ? responseBody.authorName : viewerLabel
633
+ })()
634
+ const resolvedAuthorEmail = (() => {
635
+ if (resolvedAuthorId && viewerId && resolvedAuthorId === viewerId) {
636
+ return viewerEmail ?? null
637
+ }
638
+ return typeof responseBody?.authorEmail === 'string' ? responseBody.authorEmail : null
639
+ })()
640
+ const newNote: CommentSummary = {
641
+ id: typeof responseBody?.id === 'string' ? responseBody.id : generateTempId(),
642
+ body,
643
+ createdAt: new Date().toISOString(),
644
+ authorUserId: resolvedAuthorId,
645
+ authorName: resolvedAuthorName,
646
+ authorEmail: resolvedAuthorEmail,
647
+ dealId: targetDealId,
648
+ dealTitle: dealLabel,
649
+ appearanceIcon: icon,
650
+ appearanceColor: color,
651
+ }
652
+ return [newNote, ...prev]
653
+ })
654
+ flash(label('success'), 'success')
655
+ return true
656
+ } catch (err) {
657
+ const message = err instanceof Error ? err.message : label('error')
658
+ flash(message, 'error')
659
+ return false
660
+ } finally {
661
+ setIsSubmitting(false)
662
+ popLoading()
663
+ }
664
+ },
665
+ [dataAdapter, dataContext, dealLabelMap, focusComposer, hasEntity, popLoading, pushLoading, resolvedDealId, resolvedEntityId, t, viewerEmail, viewerLabel, viewerUserId, youLabel],
666
+ )
667
+
668
+ const handleUpdateNote = React.useCallback(
669
+ async (noteId: string, patch: { body?: string; appearanceIcon?: string | null; appearanceColor?: string | null }) => {
670
+ const sanitizedBody = patch.body
671
+ const sanitizedIcon =
672
+ patch.appearanceIcon !== undefined && patch.appearanceIcon !== null && patch.appearanceIcon.trim().length
673
+ ? patch.appearanceIcon.trim()
674
+ : patch.appearanceIcon === null
675
+ ? null
676
+ : undefined
677
+ const sanitizedColor =
678
+ patch.appearanceColor !== undefined ? sanitizeHexColor(patch.appearanceColor ?? null) : undefined
679
+ try {
680
+ await dataAdapter.update({
681
+ id: noteId,
682
+ patch: {
683
+ body: sanitizedBody,
684
+ appearanceIcon: sanitizedIcon,
685
+ appearanceColor: sanitizedColor,
686
+ },
687
+ context: dataContext,
688
+ })
689
+ setNotes((prev) => {
690
+ const nextComments = prev.map((comment) => {
691
+ if (comment.id !== noteId) return comment
692
+ const next = { ...comment }
693
+ if (sanitizedBody !== undefined) next.body = sanitizedBody
694
+ if (sanitizedIcon !== undefined) next.appearanceIcon = sanitizedIcon ?? null
695
+ if (sanitizedColor !== undefined) next.appearanceColor = sanitizedColor ?? null
696
+ return next
697
+ })
698
+ return nextComments
699
+ })
700
+ flash(label('updateSuccess'), 'success')
701
+ } catch (error) {
702
+ const message = error instanceof Error ? error.message : label('updateError')
703
+ flash(message, 'error')
704
+ throw error instanceof Error ? error : new Error(message)
705
+ }
706
+ },
707
+ [dataAdapter, dataContext, t],
708
+ )
709
+
710
+ const handleDeleteNote = React.useCallback(
711
+ async (note: CommentSummary) => {
712
+ const confirmed =
713
+ typeof window === 'undefined'
714
+ ? true
715
+ : window.confirm(label('deleteConfirm', 'Delete this note? This action cannot be undone.'))
716
+ if (!confirmed) return
717
+ setDeletingNoteId(note.id)
718
+ pushLoading()
719
+ try {
720
+ await dataAdapter.delete({ id: note.id, context: dataContext })
721
+ setNotes((prev) => prev.filter((existing) => existing.id !== note.id))
722
+ flash(label('deleteSuccess', 'Note deleted'), 'success')
723
+ } catch (err) {
724
+ const message = err instanceof Error ? err.message : label('deleteError', 'Failed to delete note')
725
+ flash(message, 'error')
726
+ } finally {
727
+ setDeletingNoteId(null)
728
+ popLoading()
729
+ }
730
+ },
731
+ [dataAdapter, dataContext, popLoading, pushLoading, t],
732
+ )
733
+
734
+ const handleSubmit = React.useCallback(
735
+ async (event: React.FormEvent<HTMLFormElement>) => {
736
+ event.preventDefault()
737
+ const created = await handleCreateNote({
738
+ body: draftBody,
739
+ appearanceIcon: draftIcon,
740
+ appearanceColor: draftColor,
741
+ })
742
+ if (created) {
743
+ setDraftBody('')
744
+ setDraftIcon(null)
745
+ setDraftColor(null)
746
+ }
747
+ },
748
+ [draftBody, draftColor, draftIcon, handleCreateNote],
749
+ )
750
+
751
+ const handleLoadMore = React.useCallback(() => {
752
+ setVisibleCount((prev) => {
753
+ if (prev >= notes.length) return prev
754
+ return Math.min(prev + 5, notes.length)
755
+ })
756
+ }, [notes.length])
757
+
758
+ const handleAppearanceDialogSubmit = React.useCallback(async () => {
759
+ if (!appearanceDialogState) return
760
+ setAppearanceDialogError(null)
761
+ const sanitizedIcon =
762
+ appearanceDialogState.icon && appearanceDialogState.icon.trim().length
763
+ ? appearanceDialogState.icon.trim()
764
+ : null
765
+ const sanitizedColor = sanitizeHexColor(appearanceDialogState.color ?? null)
766
+ if (appearanceDialogState.mode === 'create') {
767
+ setDraftIcon(sanitizedIcon)
768
+ setDraftColor(sanitizedColor)
769
+ setAppearanceDialogState(null)
770
+ return
771
+ }
772
+ setAppearanceDialogSaving(true)
773
+ try {
774
+ await handleUpdateNote(appearanceDialogState.noteId, {
775
+ appearanceIcon: sanitizedIcon,
776
+ appearanceColor: sanitizedColor,
777
+ })
778
+ setAppearanceDialogState(null)
779
+ } catch (err) {
780
+ const message =
781
+ err instanceof Error
782
+ ? err.message
783
+ : label('appearance.error', 'Failed to update appearance.')
784
+ setAppearanceDialogError(message)
785
+ } finally {
786
+ setAppearanceDialogSaving(false)
787
+ }
788
+ }, [appearanceDialogState, handleUpdateNote, t])
789
+
790
+ const handleAppearanceDialogClose = React.useCallback(() => {
791
+ if (appearanceDialogSaving) return
792
+ setAppearanceDialogState(null)
793
+ setAppearanceDialogError(null)
794
+ }, [appearanceDialogSaving])
795
+
796
+ const handleContentSave = React.useCallback(async () => {
797
+ if (!contentEditor.id) return
798
+ const trimmed = contentEditor.value.trim()
799
+ if (!trimmed) {
800
+ setContentError(label('updateError', 'Failed to update note'))
801
+ return
802
+ }
803
+ setContentSavingId(contentEditor.id)
804
+ setContentError(null)
805
+ try {
806
+ await handleUpdateNote(contentEditor.id, { body: trimmed })
807
+ setContentEditor({ id: '', value: '' })
808
+ } catch (err) {
809
+ const message =
810
+ err instanceof Error ? err.message : label('updateError', 'Failed to update note')
811
+ setContentError(message)
812
+ } finally {
813
+ setContentSavingId(null)
814
+ }
815
+ }, [contentEditor, handleUpdateNote, t])
816
+
817
+ const handleContentEditorKeyDown = React.useCallback(
818
+ (event: React.KeyboardEvent) => {
819
+ if (!contentEditor.id) return
820
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
821
+ event.preventDefault()
822
+ if (!contentSavingId) void handleContentSave()
823
+ return
824
+ }
825
+ if (event.key === 'Escape') {
826
+ event.preventDefault()
827
+ setContentEditor({ id: '', value: '' })
828
+ setContentError(null)
829
+ }
830
+ },
831
+ [contentEditor.id, contentSavingId, handleContentSave],
832
+ )
833
+
834
+ const handleComposerKeyDown = React.useCallback(
835
+ (event: React.KeyboardEvent<HTMLFormElement>) => {
836
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
837
+ event.preventDefault()
838
+ formRef.current?.requestSubmit()
839
+ }
840
+ },
841
+ [],
842
+ )
843
+
844
+ const handleContentKeyDown = React.useCallback(
845
+ (event: React.KeyboardEvent<HTMLDivElement>, note: CommentSummary) => {
846
+ if (event.key === 'Enter' || event.key === ' ') {
847
+ event.preventDefault()
848
+ setContentEditor({ id: note.id, value: note.body })
849
+ }
850
+ },
851
+ [],
852
+ )
853
+
854
+ const noteAuthorLabel = React.useCallback(
855
+ (note: CommentSummary) => {
856
+ if (note.authorUserId && viewerUserId && note.authorUserId === viewerUserId) {
857
+ return youLabel
858
+ }
859
+ return note.authorName ?? note.authorEmail ?? youLabel
860
+ },
861
+ [viewerUserId, youLabel],
862
+ )
863
+
864
+ const noteAppearanceLabels = React.useMemo<AppearanceSelectorLabels>(
865
+ () => ({
866
+ colorLabel: label('appearance.colorLabel'),
867
+ colorHelp: label('appearance.colorHelp'),
868
+ colorClearLabel: label('appearance.clearColor'),
869
+ iconLabel: label('appearance.iconLabel'),
870
+ iconPlaceholder: label('appearance.iconPlaceholder'),
871
+ iconPickerTriggerLabel: label('appearance.iconPicker'),
872
+ iconSearchPlaceholder: label('appearance.iconSearchPlaceholder'),
873
+ iconSearchEmptyLabel: label('appearance.iconSearchEmpty'),
874
+ iconSuggestionsLabel: label('appearance.iconSuggestions'),
875
+ iconClearLabel: label('appearance.iconClear'),
876
+ previewEmptyLabel: label('appearance.previewEmpty'),
877
+ }),
878
+ [label],
879
+ )
880
+
881
+ const composerAuthor = React.useMemo(
882
+ () => youLabel,
883
+ [youLabel],
884
+ )
885
+ const composerHasAppearance = Boolean(draftIcon) || Boolean(draftColor)
886
+ const appearanceDialogOpen = appearanceDialogState !== null
887
+ const editingAppearanceNoteId =
888
+ appearanceDialogState?.mode === 'edit' ? appearanceDialogState.noteId : null
889
+ const addNoteShortcutLabel = label('addShortcut', 'Add note ⌘⏎ / Ctrl+Enter')
890
+ const saveAppearanceShortcutLabel = label('appearance.saveShortcut', 'Save appearance ⌘⏎ / Ctrl+Enter')
891
+ const composerSubmitLabel = addNoteShortcutLabel
892
+ const appearanceDialogPrimaryLabel = saveAppearanceShortcutLabel
893
+ const appearanceDialogSavingLabel =
894
+ appearanceDialogState?.mode === 'edit'
895
+ ? label('appearance.saving')
896
+ : label('saving', 'Saving note…')
897
+
898
+ return (
899
+ <div className="mt-0 space-y-2">
900
+ <div
901
+ className={[
902
+ 'overflow-hidden rounded-xl transition-all duration-300 ease-out',
903
+ composerOpen ? 'max-h-[1200px] bg-muted/10 p-4 opacity-100' : 'pointer-events-none max-h-0 p-0 opacity-0',
904
+ ].join(' ')}
905
+ aria-hidden={!composerOpen}
906
+ >
907
+ {composerOpen ? (
908
+ <form
909
+ ref={formRef}
910
+ onSubmit={handleSubmit}
911
+ onKeyDown={handleComposerKeyDown}
912
+ className="space-y-3"
913
+ >
914
+ <div className="flex flex-wrap items-center justify-between gap-2">
915
+ <h3 className="text-sm font-medium">{label('addLabel')}</h3>
916
+ <div className="flex flex-wrap items-center gap-1">
917
+ <Button
918
+ type="button"
919
+ variant="ghost"
920
+ size="icon"
921
+ onClick={() => {
922
+ setAppearanceDialogError(null)
923
+ setAppearanceDialogState({ mode: 'create', icon: draftIcon, color: draftColor })
924
+ }}
925
+ disabled={isSubmitting || isLoading || !hasEntity}
926
+ >
927
+ <span className="sr-only">{label('appearance.toggleOpen', 'Customize appearance')}</span>
928
+ <Palette className="h-4 w-4" />
929
+ </Button>
930
+ {disableMarkdown ? null : (
931
+ <Button
932
+ type="button"
933
+ variant={isMarkdownEnabled ? 'secondary' : 'ghost'}
934
+ size="icon"
935
+ onClick={handleMarkdownToggle}
936
+ aria-pressed={isMarkdownEnabled}
937
+ disabled={isSubmitting || isLoading}
938
+ >
939
+ <FileCode className="h-4 w-4" />
940
+ </Button>
941
+ )}
942
+ <Button
943
+ type="button"
944
+ size="sm"
945
+ variant="ghost"
946
+ onClick={() => {
947
+ setComposerOpen(false)
948
+ setDraftBody('')
949
+ setDraftIcon(null)
950
+ setDraftColor(null)
951
+ }}
952
+ disabled={isSubmitting || isLoading}
953
+ >
954
+ {inlineLabel('cancel')}
955
+ </Button>
956
+ </div>
957
+ </div>
958
+ {(normalizedEntityOptions.length || normalizedDealOptions.length) ? (
959
+ <div className="grid gap-3 sm:grid-cols-2">
960
+ {normalizedEntityOptions.length ? (
961
+ <div className="flex flex-col gap-1">
962
+ <label
963
+ htmlFor="note-entity-select"
964
+ className="text-xs font-medium text-muted-foreground"
965
+ >
966
+ {label('fields.entity', 'Assign to customer')}
967
+ </label>
968
+ <select
969
+ id="note-entity-select"
970
+ className="h-9 rounded border border-muted-foreground/40 bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
971
+ value={selectedEntityId}
972
+ onChange={(event) => setSelectedEntityId(event.target.value)}
973
+ disabled={isSubmitting || isLoading || !normalizedEntityOptions.length}
974
+ >
975
+ {normalizedEntityOptions.map((option) => (
976
+ <option key={option.id} value={option.id}>
977
+ {option.label}
978
+ </option>
979
+ ))}
980
+ </select>
981
+ </div>
982
+ ) : null}
983
+ {normalizedDealOptions.length ? (
984
+ <div className="flex flex-col gap-1">
985
+ <label
986
+ htmlFor="note-deal-select"
987
+ className="text-xs font-medium text-muted-foreground"
988
+ >
989
+ {label('fields.deal', 'Link to deal (optional)')}
990
+ </label>
991
+ <select
992
+ id="note-deal-select"
993
+ className="h-9 rounded border border-muted-foreground/40 bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
994
+ value={selectedDealId}
995
+ onChange={(event) => setSelectedDealId(event.target.value)}
996
+ disabled={isSubmitting || isLoading}
997
+ >
998
+ <option value="">
999
+ {label('fields.dealPlaceholder', 'No linked deal')}
1000
+ </option>
1001
+ {normalizedDealOptions.map((option) => (
1002
+ <option key={option.id} value={option.id}>
1003
+ {option.label}
1004
+ </option>
1005
+ ))}
1006
+ </select>
1007
+ </div>
1008
+ ) : null}
1009
+ </div>
1010
+ ) : null}
1011
+ <SwitchableMarkdownInput
1012
+ value={draftBody}
1013
+ onChange={setDraftBody}
1014
+ isMarkdownEnabled={isMarkdownEnabled}
1015
+ disableMarkdown={disableMarkdown}
1016
+ rows={1}
1017
+ placeholder={label('placeholder')}
1018
+ textareaRef={textareaRef}
1019
+ onTextareaInput={(event) => adjustTextareaSize(event.currentTarget)}
1020
+ disabled={isSubmitting || isLoading || !hasEntity}
1021
+ remarkPlugins={markdownPlugins}
1022
+ />
1023
+ {composerHasAppearance ? (
1024
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-dashed border-muted-foreground/40 px-3 py-2">
1025
+ <div className="flex flex-wrap items-center gap-3 text-sm">
1026
+ {draftIcon && renderIcon ? (
1027
+ <span className="inline-flex h-7 w-7 items-center justify-center rounded border border-border bg-muted/40">
1028
+ {renderIcon(draftIcon, 'h-4 w-4')}
1029
+ </span>
1030
+ ) : null}
1031
+ <span className="font-semibold text-foreground">{composerAuthor}</span>
1032
+ {draftColor && renderColor ? (
1033
+ <span className="flex items-center gap-2">
1034
+ {renderColor(draftColor, 'h-3.5 w-3.5 rounded-full border border-border')}
1035
+ <span className="text-xs font-medium uppercase text-muted-foreground">{draftColor}</span>
1036
+ </span>
1037
+ ) : null}
1038
+ </div>
1039
+ <Button
1040
+ type="button"
1041
+ size="sm"
1042
+ variant="ghost"
1043
+ onClick={() => {
1044
+ setDraftIcon(null)
1045
+ setDraftColor(null)
1046
+ }}
1047
+ disabled={isSubmitting}
1048
+ >
1049
+ {label('appearance.clearAll', 'Clear')}
1050
+ </Button>
1051
+ </div>
1052
+ ) : null}
1053
+ <div className="flex justify-end">
1054
+ <Button
1055
+ type="submit"
1056
+ size="sm"
1057
+ disabled={isSubmitting || isLoading || !hasEntity}
1058
+ >
1059
+ {isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
1060
+ {composerSubmitLabel}
1061
+ </Button>
1062
+ </div>
1063
+ </form>
1064
+ ) : null}
1065
+ </div>
1066
+
1067
+ {loadError ? <ErrorMessage label={loadError} className="mt-3" /> : null}
1068
+
1069
+ <div className="space-y-3">
1070
+ {isLoading ? (
1071
+ <LoadingMessage
1072
+ label={label('loading', 'Loading notes…')}
1073
+ className="border-0 bg-transparent p-0 py-8 justify-center"
1074
+ />
1075
+ ) : hasVisibleNotes ? (
1076
+ visibleNotes.map((note) => {
1077
+ const author = noteAuthorLabel(note)
1078
+ const isAppearanceSaving = appearanceDialogSaving && editingAppearanceNoteId === note.id
1079
+ const isEditingContent = contentEditor.id === note.id
1080
+ const displayIcon = note.appearanceIcon ?? null
1081
+ const displayColor = note.appearanceColor ?? null
1082
+ const timestampValue = note.createdAt
1083
+ const fallbackTimestampLabel = formatDateTime(note.createdAt) ?? emptyLabel
1084
+ return (
1085
+ <div key={note.id} className="group space-y-2 rounded-lg border bg-card p-4">
1086
+ <div className="flex flex-wrap items-start justify-between gap-3">
1087
+ <div className="space-y-1">
1088
+ <TimelineItemHeader
1089
+ title={author}
1090
+ timestamp={timestampValue}
1091
+ fallbackTimestampLabel={fallbackTimestampLabel}
1092
+ icon={displayIcon}
1093
+ color={displayColor}
1094
+ renderIcon={renderIcon}
1095
+ renderColor={renderColor}
1096
+ />
1097
+ {note.dealId ? (
1098
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
1099
+ <ArrowUpRightSquare className="h-3.5 w-3.5" />
1100
+ <a
1101
+ href={`/backend/customers/deals/${encodeURIComponent(note.dealId)}`}
1102
+ className="font-medium text-foreground hover:underline"
1103
+ >
1104
+ {note.dealTitle && note.dealTitle.length
1105
+ ? note.dealTitle
1106
+ : label('linkedDeal', 'Linked deal')}
1107
+ </a>
1108
+ </div>
1109
+ ) : null}
1110
+ </div>
1111
+ <div
1112
+ className={`flex items-center gap-2 transition-opacity ${
1113
+ isEditingContent ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'
1114
+ }`}
1115
+ >
1116
+ <Button
1117
+ type="button"
1118
+ variant="ghost"
1119
+ size="icon"
1120
+ onClick={() => setContentEditor({ id: note.id, value: note.body })}
1121
+ >
1122
+ <Pencil className="h-4 w-4" />
1123
+ </Button>
1124
+ <Button
1125
+ type="button"
1126
+ variant="ghost"
1127
+ size="icon"
1128
+ onClick={(event) => {
1129
+ event.stopPropagation()
1130
+ setAppearanceDialogError(null)
1131
+ setAppearanceDialogState({
1132
+ mode: 'edit',
1133
+ noteId: note.id,
1134
+ icon: note.appearanceIcon ?? null,
1135
+ color: note.appearanceColor ?? null,
1136
+ })
1137
+ }}
1138
+ disabled={appearanceDialogSaving && editingAppearanceNoteId === note.id}
1139
+ >
1140
+ {isAppearanceSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Palette className="h-4 w-4" />}
1141
+ </Button>
1142
+ <Button
1143
+ type="button"
1144
+ variant="ghost"
1145
+ size="icon"
1146
+ onClick={(event) => {
1147
+ event.stopPropagation()
1148
+ void handleDeleteNote(note)
1149
+ }}
1150
+ disabled={deletingNoteId === note.id}
1151
+ >
1152
+ {deletingNoteId === note.id ? (
1153
+ <span className="relative flex h-4 w-4 items-center justify-center text-destructive">
1154
+ <span className="absolute h-4 w-4 animate-spin rounded-full border border-destructive border-t-transparent" />
1155
+ </span>
1156
+ ) : (
1157
+ <Trash2 className="h-4 w-4" />
1158
+ )}
1159
+ </Button>
1160
+ </div>
1161
+ </div>
1162
+ {isEditingContent ? (
1163
+ <div className="space-y-2" onKeyDown={handleContentEditorKeyDown}>
1164
+ <SwitchableMarkdownInput
1165
+ value={contentEditor.value}
1166
+ onChange={(nextValue) => setContentEditor((prev) => ({ ...prev, value: nextValue }))}
1167
+ isMarkdownEnabled={isMarkdownEnabled}
1168
+ disableMarkdown={disableMarkdown}
1169
+ rows={3}
1170
+ textareaRef={contentTextareaRef}
1171
+ onTextareaInput={(event) => adjustTextareaSize(event.currentTarget)}
1172
+ textareaClassName="w-full resize-none overflow-hidden rounded-md border border-border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
1173
+ editorWrapperClassName="w-full rounded-md border border-muted-foreground/20 bg-background p-2"
1174
+ remarkPlugins={markdownPlugins}
1175
+ />
1176
+ {contentError ? <p className="text-xs text-red-600">{contentError}</p> : null}
1177
+ <div className="flex flex-wrap items-center gap-2">
1178
+ <Button type="button" size="sm" onClick={handleContentSave} disabled={contentSavingId === note.id}>
1179
+ {contentSavingId === note.id ? (
1180
+ <>
1181
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1182
+ {label('saving')}
1183
+ </>
1184
+ ) : (
1185
+ inlineLabel('saveShortcut')
1186
+ )}
1187
+ </Button>
1188
+ {disableMarkdown ? null : (
1189
+ <Button
1190
+ type="button"
1191
+ variant="ghost"
1192
+ size="icon"
1193
+ onClick={handleMarkdownToggle}
1194
+ aria-pressed={isMarkdownEnabled}
1195
+ className={isMarkdownEnabled ? 'text-primary' : undefined}
1196
+ disabled={contentSavingId === note.id}
1197
+ >
1198
+ <FileCode className="h-4 w-4" />
1199
+ </Button>
1200
+ )}
1201
+ <Button
1202
+ type="button"
1203
+ size="sm"
1204
+ variant="ghost"
1205
+ onClick={() => setContentEditor({ id: '', value: '' })}
1206
+ disabled={contentSavingId === note.id}
1207
+ >
1208
+ {inlineLabel('cancel')}
1209
+ </Button>
1210
+ </div>
1211
+ </div>
1212
+ ) : (
1213
+ <div
1214
+ role="button"
1215
+ tabIndex={0}
1216
+ className="cursor-pointer text-sm"
1217
+ onClick={() => setContentEditor({ id: note.id, value: note.body })}
1218
+ onKeyDown={(event) => handleContentKeyDown(event, note)}
1219
+ >
1220
+ <MarkdownPreviewComponent
1221
+ remarkPlugins={markdownPlugins}
1222
+ className="break-words text-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs"
1223
+ >
1224
+ {note.body}
1225
+ </MarkdownPreviewComponent>
1226
+ </div>
1227
+ )}
1228
+ </div>
1229
+ )
1230
+ })
1231
+ ) : composerOpen ? null : (
1232
+ <TabEmptyState
1233
+ title={emptyState.title}
1234
+ description={emptyState.description}
1235
+ action={{
1236
+ label: emptyState.actionLabel,
1237
+ onClick: focusComposer,
1238
+ disabled: isSubmitting || !hasEntity,
1239
+ }}
1240
+ />
1241
+ )}
1242
+ {isLoading || visibleCount >= notes.length ? null : (
1243
+ <div className="flex justify-center">
1244
+ <Button variant="outline" size="sm" onClick={handleLoadMore}>
1245
+ {loadMoreLabel}
1246
+ </Button>
1247
+ </div>
1248
+ )}
1249
+ </div>
1250
+ <AppearanceDialog
1251
+ open={appearanceDialogOpen}
1252
+ title={
1253
+ appearanceDialogState?.mode === 'edit'
1254
+ ? label('appearance.edit')
1255
+ : label('appearance.toggleOpen', 'Customize appearance')
1256
+ }
1257
+ icon={appearanceDialogState?.icon ?? null}
1258
+ color={appearanceDialogState?.color ?? null}
1259
+ labels={noteAppearanceLabels}
1260
+ iconSuggestions={iconSuggestions}
1261
+ onIconChange={(value) => setAppearanceDialogState((prev) => (prev ? { ...prev, icon: value ?? null } : prev))}
1262
+ onColorChange={(value) => setAppearanceDialogState((prev) => (prev ? { ...prev, color: value ?? null } : prev))}
1263
+ onSubmit={() => {
1264
+ void handleAppearanceDialogSubmit()
1265
+ }}
1266
+ onClose={handleAppearanceDialogClose}
1267
+ isSaving={appearanceDialogSaving}
1268
+ errorMessage={appearanceDialogError}
1269
+ primaryLabel={appearanceDialogPrimaryLabel}
1270
+ savingLabel={appearanceDialogSavingLabel}
1271
+ cancelLabel={label('appearance.cancel')}
1272
+ />
1273
+ </div>
1274
+ )
1275
+ }