@carlonicora/nextjs-jsonapi 3.24.2 → 3.26.0

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 (220) hide show
  1. package/dist/{AssistantMessageInterface-1Kbz8RVs.d.ts → AssistantMessageInterface-CubxuBIJ.d.ts} +1 -1
  2. package/dist/{AssistantMessageInterface-CoxJyuFM.d.mts → AssistantMessageInterface-sHjI2zPN.d.mts} +1 -1
  3. package/dist/{BlockNoteEditor-35D4R7HJ.js → BlockNoteEditor-4EIKMNY3.js} +31 -21
  4. package/dist/BlockNoteEditor-4EIKMNY3.js.map +1 -0
  5. package/dist/{BlockNoteEditor-65HR6ULV.mjs → BlockNoteEditor-AXYVDDXM.mjs} +16 -6
  6. package/dist/{BlockNoteEditor-65HR6ULV.mjs.map → BlockNoteEditor-AXYVDDXM.mjs.map} +1 -1
  7. package/dist/{BlockNoteViewer-QDXDVYEM.js → BlockNoteViewer-7BFNTN4E.js} +4 -4
  8. package/dist/{BlockNoteViewer-QDXDVYEM.js.map → BlockNoteViewer-7BFNTN4E.js.map} +1 -1
  9. package/dist/{BlockNoteViewer-KDKGUIG5.mjs → BlockNoteViewer-KRTAFTUX.mjs} +3 -3
  10. package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-Ck1wZJ81.d.ts} +1 -1
  11. package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-DgYH--Ik.d.mts} +1 -1
  12. package/dist/JsonApiRequest-TE5WD3WS.js +26 -0
  13. package/dist/{JsonApiRequest-G625QAH5.js.map → JsonApiRequest-TE5WD3WS.js.map} +1 -1
  14. package/dist/{JsonApiRequest-RRMNV7YE.mjs → JsonApiRequest-UHOWNI3O.mjs} +2 -2
  15. package/dist/analytics.modules-CfczU12P.d.ts +25 -0
  16. package/dist/analytics.modules-pzGwMASI.d.mts +25 -0
  17. package/dist/{auth.interface-BC1jp_ni.d.ts → auth.interface-BzI4onI_.d.ts} +1 -1
  18. package/dist/{auth.interface-dR2-6gPM.d.mts → auth.interface-Df4vXbNz.d.mts} +1 -1
  19. package/dist/billing/index.js +402 -402
  20. package/dist/billing/index.mjs +4 -4
  21. package/dist/{chunk-YXRVPEEJ.js → chunk-26MQIJNB.js} +1 -1
  22. package/dist/{chunk-YXRVPEEJ.js.map → chunk-26MQIJNB.js.map} +1 -1
  23. package/dist/{chunk-T76WLDE5.mjs → chunk-37DMFVLN.mjs} +1 -1
  24. package/dist/{chunk-3AR4KA5D.js → chunk-4Z45NYPR.js} +13 -13
  25. package/dist/{chunk-3AR4KA5D.js.map → chunk-4Z45NYPR.js.map} +1 -1
  26. package/dist/chunk-7B4E42R5.mjs +280 -0
  27. package/dist/chunk-7B4E42R5.mjs.map +1 -0
  28. package/dist/{chunk-2XCUAHQT.js → chunk-ABVTYQIJ.js} +1563 -1336
  29. package/dist/chunk-ABVTYQIJ.js.map +1 -0
  30. package/dist/{chunk-U7QVISQS.js → chunk-CAKOFJZD.js} +450 -40
  31. package/dist/chunk-CAKOFJZD.js.map +1 -0
  32. package/dist/{chunk-IBVKPLHT.mjs → chunk-E3I4MT53.mjs} +422 -12
  33. package/dist/chunk-E3I4MT53.mjs.map +1 -0
  34. package/dist/chunk-GHFMKH3M.js +280 -0
  35. package/dist/chunk-GHFMKH3M.js.map +1 -0
  36. package/dist/chunk-GKDHYB2R.js +110 -0
  37. package/dist/chunk-GKDHYB2R.js.map +1 -0
  38. package/dist/{chunk-ZJI5ZZW5.mjs → chunk-Q6J2ICKO.mjs} +3 -3
  39. package/dist/{chunk-4O7R2Z2O.mjs → chunk-QWZAGQWJ.mjs} +2175 -1948
  40. package/dist/chunk-QWZAGQWJ.mjs.map +1 -0
  41. package/dist/chunk-U65JPKO3.mjs +110 -0
  42. package/dist/chunk-U65JPKO3.mjs.map +1 -0
  43. package/dist/client/index.d.mts +6 -5
  44. package/dist/client/index.d.ts +6 -5
  45. package/dist/client/index.js +5 -5
  46. package/dist/client/index.js.map +1 -1
  47. package/dist/client/index.mjs +4 -4
  48. package/dist/components/index.d.mts +33 -10
  49. package/dist/components/index.d.ts +33 -10
  50. package/dist/components/index.js +7 -5
  51. package/dist/components/index.js.map +1 -1
  52. package/dist/components/index.mjs +6 -4
  53. package/dist/{config-B6UB7j69.d.ts → config-3N1LSrcC.d.ts} +1 -1
  54. package/dist/{config-Cd5xTEfZ.d.ts → config-AVtuvfM1.d.ts} +1 -1
  55. package/dist/{config-n-ZpPmOL.d.mts → config-BSKTYDoh.d.mts} +1 -1
  56. package/dist/{config-YyhFHxkU.d.mts → config-BmufyEj6.d.mts} +1 -1
  57. package/dist/contexts/index.d.mts +5 -4
  58. package/dist/contexts/index.d.ts +5 -4
  59. package/dist/contexts/index.js +5 -5
  60. package/dist/contexts/index.mjs +4 -4
  61. package/dist/core/index.d.mts +31 -14
  62. package/dist/core/index.d.ts +31 -14
  63. package/dist/core/index.js +9 -5
  64. package/dist/core/index.js.map +1 -1
  65. package/dist/core/index.mjs +8 -4
  66. package/dist/features/analytics/index.css +30 -0
  67. package/dist/features/analytics/index.css.map +1 -0
  68. package/dist/features/analytics/index.d.mts +508 -0
  69. package/dist/features/analytics/index.d.ts +508 -0
  70. package/dist/features/analytics/index.js +1166 -0
  71. package/dist/features/analytics/index.js.map +1 -0
  72. package/dist/features/analytics/index.mjs +1166 -0
  73. package/dist/features/analytics/index.mjs.map +1 -0
  74. package/dist/features/analytics/server.d.mts +28 -0
  75. package/dist/features/analytics/server.d.ts +28 -0
  76. package/dist/features/analytics/server.js +41 -0
  77. package/dist/features/analytics/server.js.map +1 -0
  78. package/dist/features/analytics/server.mjs +41 -0
  79. package/dist/features/analytics/server.mjs.map +1 -0
  80. package/dist/features/help/index.d.mts +2 -2
  81. package/dist/features/help/index.d.ts +2 -2
  82. package/dist/features/help/index.js +43 -43
  83. package/dist/features/help/index.js.map +1 -1
  84. package/dist/features/help/index.mjs +9 -9
  85. package/dist/features/help/index.mjs.map +1 -1
  86. package/dist/features/tokenusage/index.d.mts +2 -2
  87. package/dist/features/tokenusage/index.d.ts +2 -2
  88. package/dist/features/tokenusage/index.js +141 -223
  89. package/dist/features/tokenusage/index.js.map +1 -1
  90. package/dist/features/tokenusage/index.mjs +28 -110
  91. package/dist/features/tokenusage/index.mjs.map +1 -1
  92. package/dist/index.d.mts +11 -9
  93. package/dist/index.d.ts +11 -9
  94. package/dist/index.js +10 -6
  95. package/dist/index.js.map +1 -1
  96. package/dist/index.mjs +9 -5
  97. package/dist/notification.interface-BNCvnVCW.d.ts +34 -0
  98. package/dist/notification.interface-P_pNfzZQ.d.mts +34 -0
  99. package/dist/{s3.service-Bqr-fQmi.d.ts → s3.service-CbUa6a-8.d.ts} +3 -2
  100. package/dist/{s3.service-Buiy1ncj.d.mts → s3.service-DAp8FbuG.d.mts} +3 -2
  101. package/dist/server/index.d.mts +5 -4
  102. package/dist/server/index.d.ts +5 -4
  103. package/dist/server/index.js +39 -279
  104. package/dist/server/index.js.map +1 -1
  105. package/dist/server/index.mjs +20 -260
  106. package/dist/server/index.mjs.map +1 -1
  107. package/dist/{types-bsm8Fp55.d.ts → types-Cn153-mH.d.ts} +1 -1
  108. package/dist/{types-Bq_UA8Lg.d.mts → types-D0uLYV2P.d.mts} +1 -1
  109. package/dist/{usePageUrlGenerator-J_ECv-oQ.d.ts → usePageUrlGenerator-B-3FRKBD.d.ts} +1 -1
  110. package/dist/{usePageUrlGenerator-uKBjAKqC.d.mts → usePageUrlGenerator-CCJ_cdQy.d.mts} +1 -1
  111. package/dist/{useSocket-BcH7l7B8.d.mts → useSocket-B9gTbuCj.d.mts} +1 -1
  112. package/dist/{useSocket-DCZd_eJk.d.ts → useSocket-BHVIyigI.d.ts} +1 -1
  113. package/dist/{notification.interface-MnRt1Tjm.d.mts → user.interface-CSaGWFbg.d.mts} +1 -31
  114. package/dist/{notification.interface-CjzV_KII.d.ts → user.interface-CrmwJLYW.d.ts} +1 -31
  115. package/package.json +11 -1
  116. package/src/client/index.ts +2 -0
  117. package/src/components/containers/PageContainer.tsx +1 -1
  118. package/src/components/containers/RoundPageContainer.tsx +60 -16
  119. package/src/components/containers/RoundPageContainerTitle.tsx +75 -35
  120. package/src/components/editors/BlockNoteEditor.tsx +13 -1
  121. package/src/components/forms/EntityMultiSelector.tsx +4 -4
  122. package/src/components/forms/EntitySelector.tsx +4 -4
  123. package/src/components/forms/FormBlockNote.tsx +21 -1
  124. package/src/components/forms/FormCheckbox.tsx +6 -2
  125. package/src/components/forms/__tests__/useEditorDialog.test.ts +47 -0
  126. package/src/components/forms/useEditorDialog.ts +22 -0
  127. package/src/components/navigations/Breadcrumb.tsx +45 -16
  128. package/src/components/navigations/Header.tsx +16 -3
  129. package/src/components/navigations/MobileNavigationBar.tsx +12 -0
  130. package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +17 -0
  131. package/src/components/tables/ContentListTable.tsx +52 -32
  132. package/src/core/index.ts +4 -0
  133. package/src/core/registry/ModuleRegistry.ts +7 -0
  134. package/src/features/__tests__/i18n-keys.contract.spec.ts +1 -0
  135. package/src/features/analytics/__tests__/server-entry.spec.tsx +64 -0
  136. package/src/features/analytics/analytics.modules.ts +30 -0
  137. package/src/features/analytics/components/AnalyticsAdminContainer.tsx +97 -0
  138. package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +120 -0
  139. package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +25 -0
  140. package/src/features/analytics/components/AnalyticsAdminTiles.tsx +191 -0
  141. package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +105 -0
  142. package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +246 -0
  143. package/src/features/analytics/components/AnalyticsJourneyPageContainer.tsx +14 -0
  144. package/src/features/analytics/components/AnalyticsSessionsTable.tsx +107 -0
  145. package/src/features/analytics/components/AnalyticsTimelineChart.tsx +175 -0
  146. package/src/features/analytics/components/AnalyticsTracker.tsx +112 -0
  147. package/src/features/analytics/components/__tests__/AnalyticsAdminTiles.spec.tsx +39 -0
  148. package/src/features/analytics/components/__tests__/AnalyticsBreakdownTable.spec.tsx +39 -0
  149. package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +124 -0
  150. package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +117 -0
  151. package/src/features/analytics/components/__tests__/AnalyticsSessionsTable.spec.tsx +121 -0
  152. package/src/features/analytics/components/__tests__/AnalyticsTimelineChart.spec.tsx +35 -0
  153. package/src/features/analytics/components/__tests__/AnalyticsTracker.spec.tsx +248 -0
  154. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +28 -0
  155. package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +25 -0
  156. package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +273 -0
  157. package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +140 -0
  158. package/src/features/analytics/data/AnalyticsAdminService.ts +91 -0
  159. package/src/features/analytics/data/AnalyticsService.ts +23 -0
  160. package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +117 -0
  161. package/src/features/analytics/data/__tests__/AnalyticsService.spec.ts +56 -0
  162. package/src/features/analytics/data/__tests__/models.spec.ts +300 -0
  163. package/src/features/analytics/data/analytics-breakdown.interface.ts +9 -0
  164. package/src/features/analytics/data/analytics-breakdown.ts +65 -0
  165. package/src/features/analytics/data/analytics-event.interface.ts +7 -0
  166. package/src/features/analytics/data/analytics-event.ts +63 -0
  167. package/src/features/analytics/data/analytics-page-view.interface.ts +9 -0
  168. package/src/features/analytics/data/analytics-page-view.ts +57 -0
  169. package/src/features/analytics/data/analytics-session.interface.ts +23 -0
  170. package/src/features/analytics/data/analytics-session.ts +144 -0
  171. package/src/features/analytics/data/analytics-summary.interface.ts +11 -0
  172. package/src/features/analytics/data/analytics-summary.ts +79 -0
  173. package/src/features/analytics/data/analytics-timeline.interface.ts +10 -0
  174. package/src/features/analytics/data/analytics-timeline.ts +67 -0
  175. package/src/features/analytics/data/analytics.types.ts +97 -0
  176. package/src/features/analytics/data/index.ts +15 -0
  177. package/src/features/analytics/i18n-keys.ts +76 -0
  178. package/src/features/analytics/index.ts +31 -0
  179. package/src/features/analytics/lib/__tests__/utm.spec.ts +20 -0
  180. package/src/features/analytics/lib/__tests__/visitorCookie.spec.ts +63 -0
  181. package/src/features/analytics/lib/utm.ts +29 -0
  182. package/src/features/analytics/lib/visitorCookie.ts +32 -0
  183. package/src/features/analytics/server-entry.ts +8 -0
  184. package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +1 -1
  185. package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
  186. package/src/features/assistant-message/components/MessageItem.tsx +2 -2
  187. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
  188. package/src/features/auth/components/containers/AuthContainer.tsx +8 -1
  189. package/src/features/company/components/details/CompanyContent.tsx +3 -1
  190. package/src/features/company/components/details/CompanyDetails.tsx +9 -1
  191. package/src/features/company/components/details/TokenStatusIndicator.tsx +42 -13
  192. package/src/features/help/components/HelpHeader.tsx +7 -5
  193. package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +9 -6
  194. package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +21 -0
  195. package/src/features/tokenusage/lib/formatters.ts +5 -1
  196. package/src/features/waitlist/components/lists/WaitlistList.tsx +68 -121
  197. package/src/features/waitlist/data/WaitlistService.ts +2 -2
  198. package/src/features/waitlist/data/index.ts +1 -0
  199. package/src/features/waitlist/data/waitlist.fields.ts +8 -0
  200. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +69 -30
  201. package/src/shadcnui/ui/button.tsx +1 -1
  202. package/src/shadcnui/ui/checkbox.tsx +1 -1
  203. package/src/shadcnui/ui/command.tsx +4 -4
  204. package/src/shadcnui/ui/dialog.tsx +2 -0
  205. package/src/shadcnui/ui/input.tsx +1 -1
  206. package/src/shadcnui/ui/popover.tsx +9 -0
  207. package/src/shadcnui/ui/select.tsx +1 -1
  208. package/src/shadcnui/ui/sheet.tsx +8 -1
  209. package/src/shadcnui/ui/table.tsx +2 -2
  210. package/src/shadcnui/ui/textarea.tsx +1 -1
  211. package/dist/BlockNoteEditor-35D4R7HJ.js.map +0 -1
  212. package/dist/JsonApiRequest-G625QAH5.js +0 -26
  213. package/dist/chunk-2XCUAHQT.js.map +0 -1
  214. package/dist/chunk-4O7R2Z2O.mjs.map +0 -1
  215. package/dist/chunk-IBVKPLHT.mjs.map +0 -1
  216. package/dist/chunk-U7QVISQS.js.map +0 -1
  217. /package/dist/{BlockNoteViewer-KDKGUIG5.mjs.map → BlockNoteViewer-KRTAFTUX.mjs.map} +0 -0
  218. /package/dist/{JsonApiRequest-RRMNV7YE.mjs.map → JsonApiRequest-UHOWNI3O.mjs.map} +0 -0
  219. /package/dist/{chunk-T76WLDE5.mjs.map → chunk-37DMFVLN.mjs.map} +0 -0
  220. /package/dist/{chunk-ZJI5ZZW5.mjs.map → chunk-Q6J2ICKO.mjs.map} +0 -0
@@ -7,6 +7,14 @@ import { BlockNoteEditorContainer } from "../editors/BlockNoteEditorContainer";
7
7
  import type { MentionNameResolver, MentionResolveFn } from "../editors/BlockNoteEditorMentionInlineContent";
8
8
  import { FormFieldWrapper } from "./FormFieldWrapper";
9
9
 
10
+ function isEmptyDocument(value: unknown): boolean {
11
+ return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
12
+ }
13
+
14
+ function getPath(source: unknown, path: string): unknown {
15
+ return path.split(".").reduce<unknown>((acc, key) => (acc == null ? undefined : (acc as Record<string, unknown>)[key]), source);
16
+ }
17
+
10
18
  export function FormBlockNote({
11
19
  form,
12
20
  id,
@@ -73,6 +81,10 @@ export function FormBlockNote({
73
81
  className={cn(
74
82
  "flex w-full flex-col",
75
83
  stretch && "min-h-0 flex-1 [&>[data-slot=field]]:min-h-0 [&>[data-slot=field]]:flex-1",
84
+ // Phone: the sheet is too short to share with the other fields, so the
85
+ // editor stops filling the leftover space (2px in a long form) and grows
86
+ // with its content instead; the sheet body does the scrolling.
87
+ stretch && "max-md:min-h-64 max-md:flex-none",
76
88
  className,
77
89
  )}
78
90
  >
@@ -100,7 +112,15 @@ export function FormBlockNote({
100
112
  initialContent={initialContentRef.current}
101
113
  onChange={(content, isEmpty) => {
102
114
  lastEditorContentRef.current = content;
103
- field.onChange(content);
115
+ // BlockNote normalises an empty document ([] / null) to one empty
116
+ // paragraph as soon as it mounts. That is not a user edit: re-seed the
117
+ // field's default with it so an untouched form does not ask
118
+ // "Unsaved changes?" on close.
119
+ if (isEmpty && isEmptyDocument(getPath(form.formState.defaultValues, id)) && !form.getFieldState(id).isDirty) {
120
+ form.resetField(id, { defaultValue: content });
121
+ } else {
122
+ field.onChange(content);
123
+ }
104
124
  onEmptyChange?.(isEmpty);
105
125
  }}
106
126
  placeholder={placeholder}
@@ -15,7 +15,11 @@ type FormCheckboxProps = {
15
15
  export function FormCheckbox({ form, id, name, labelBefore, description, isRequired }: FormCheckboxProps) {
16
16
  const simpleLabel = () => {
17
17
  return (
18
- <FieldLabel htmlFor={id} className={`font-normal ${labelBefore ? "" : "ms-3"}`}>
18
+ // Phone: the label fills the row and is 40px tall, so the whole row toggles the box.
19
+ <FieldLabel
20
+ htmlFor={id}
21
+ className={`font-normal ${labelBefore ? "" : "ms-3"} max-md:min-h-10 max-md:flex-1`}
22
+ >
19
23
  {name}
20
24
  </FieldLabel>
21
25
  );
@@ -36,7 +40,7 @@ export function FormCheckbox({ form, id, name, labelBefore, description, isRequi
36
40
  <div className="flex w-full flex-col">
37
41
  <FormFieldWrapper form={form} name={id} orientation="horizontal">
38
42
  {(field) => (
39
- <div className="flex gap-x-4">
43
+ <div className="flex gap-x-4 max-md:min-h-10 max-md:items-center">
40
44
  {labelBefore && label()}
41
45
  {labelBefore && isRequired && <span className="ms-1 text-destructive">*</span>}
42
46
  <Checkbox id={id} checked={field.value ?? false} onCheckedChange={field.onChange} />
@@ -169,6 +169,53 @@ describe("useEditorDialog", () => {
169
169
  });
170
170
  });
171
171
 
172
+ describe("escape key with an open picker / menu above the sheet", () => {
173
+ const pressEscape = () => {
174
+ const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
175
+ act(() => {
176
+ document.dispatchEvent(event);
177
+ });
178
+ return event;
179
+ };
180
+
181
+ const mountLayer = (html: string) => {
182
+ const host = document.createElement("div");
183
+ host.innerHTML = html;
184
+ document.body.appendChild(host);
185
+ return () => host.remove();
186
+ };
187
+
188
+ it.each([
189
+ ['<div data-slot="popover-content" data-open></div>'],
190
+ ['<div data-slot="select-content" data-open></div>'],
191
+ ['<div data-slot="dropdown-menu-content" data-open></div>'],
192
+ ['<div data-slot="dialog-content" data-open></div>'],
193
+ ['<div class="bn-suggestion-menu"></div>'],
194
+ ])("leaves Escape to the open layer %s and keeps the sheet open", (html) => {
195
+ const { result } = renderHook(() => useEditorDialog(() => true));
196
+ act(() => result.current.setOpen(true));
197
+ const unmount = mountLayer(html);
198
+
199
+ const event = pressEscape();
200
+
201
+ expect(result.current.open).toBe(true);
202
+ expect(result.current.discardDialogProps.open).toBe(false);
203
+ expect(event.defaultPrevented).toBe(false);
204
+ unmount();
205
+ });
206
+
207
+ it("handles Escape again once the layer has closed", () => {
208
+ const { result } = renderHook(() => useEditorDialog(() => false));
209
+ act(() => result.current.setOpen(true));
210
+ const unmount = mountLayer('<div data-slot="popover-content" data-closed></div>');
211
+
212
+ pressEscape();
213
+
214
+ expect(result.current.open).toBe(false);
215
+ unmount();
216
+ });
217
+ });
218
+
172
219
  describe("escape key with nested editors", () => {
173
220
  const pressEscape = () =>
174
221
  act(() => {
@@ -11,6 +11,27 @@ import { useCallback, useEffect, useRef, useState } from "react";
11
11
  // editor that opened it.
12
12
  const openEditorStack: object[] = [];
13
13
 
14
+ // Layers that open ABOVE an editor sheet and close on their own Escape: pickers
15
+ // (popovers), selects, menus, nested dialogs, the discard confirmation, and the
16
+ // rich-text editor's slash / mention menu. Our listener runs in the CAPTURE
17
+ // phase on `document`, so it sees Escape before any of them — if it handled the
18
+ // key it would stopPropagation() and close the whole sheet while the picker
19
+ // stayed open underneath. While one of these is open, Escape is theirs.
20
+ const INNER_LAYER_SELECTOR = [
21
+ "[data-slot=popover-content][data-open]",
22
+ "[data-slot=select-content][data-open]",
23
+ "[data-slot=combobox-content][data-open]",
24
+ "[data-slot=dropdown-menu-content][data-open]",
25
+ "[data-slot=context-menu-content][data-open]",
26
+ "[data-slot=dialog-content][data-open]",
27
+ "[data-slot=alert-dialog-content][data-open]",
28
+ ".bn-suggestion-menu",
29
+ ].join(",");
30
+
31
+ export function hasOpenInnerLayer(doc: Document = document): boolean {
32
+ return doc.querySelector(INNER_LAYER_SELECTOR) !== null;
33
+ }
34
+
14
35
  type UseEditorDialogOptions = {
15
36
  dialogOpen?: boolean;
16
37
  onDialogOpenChange?: (open: boolean) => void;
@@ -116,6 +137,7 @@ export function useEditorDialog(isFormDirty: () => boolean, options?: UseEditorD
116
137
  const handleKeyDown = (event: KeyboardEvent) => {
117
138
  if (event.key === "Escape" && open) {
118
139
  if (openEditorStack[openEditorStack.length - 1] !== dialogId.current) return;
140
+ if (hasOpenInnerLayer()) return;
119
141
  event.preventDefault();
120
142
  event.stopPropagation();
121
143
  handleOpenChange(false);
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import { Fragment, useState } from "react";
4
+ import { Fragment, useState, type MouseEvent } from "react";
5
5
  import { ChevronDownIcon } from "lucide-react";
6
- import { useIsMobile } from "../../utils";
6
+ import { cn, useIsMobile } from "../../utils";
7
7
  import { usePageUrlGenerator } from "../../hooks";
8
8
  import { BreadcrumbItemData } from "../../interfaces";
9
9
  import {
@@ -147,24 +147,53 @@ function BreadcrumbMobile({
147
147
  );
148
148
  }
149
149
 
150
+ // One entry means the menu could only offer the page already on screen, so
151
+ // the chevron promised a choice that does not exist: show plain text.
152
+ if (allItems.length <= 1) {
153
+ return (
154
+ <span className="text-foreground block min-w-0 truncate px-1.5 text-xs/relaxed font-normal">
155
+ {lastItem?.name}
156
+ </span>
157
+ );
158
+ }
159
+
150
160
  return (
151
161
  <DropdownMenu open={open} onOpenChange={setOpen}>
152
- <DropdownMenuTrigger className="text-foreground text-xs/relaxed font-normal hover:bg-accent flex items-center gap-1 rounded-md px-1.5 py-0.5 transition-colors outline-none">
153
- {lastItem?.name}
154
- <ChevronDownIcon className="text-muted-foreground size-3.5" />
162
+ {/* min-h-9: a 36px tap target, where the bare text row was ~20px. */}
163
+ <DropdownMenuTrigger className="text-foreground text-xs/relaxed font-normal hover:bg-accent flex min-h-9 min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 transition-colors outline-none">
164
+ <span className="truncate">{lastItem?.name}</span>
165
+ <ChevronDownIcon className="text-muted-foreground size-3.5 shrink-0" />
155
166
  </DropdownMenuTrigger>
156
167
  <DropdownMenuContent align="start">
157
- {allItems.map((item, index) => (
158
- <DropdownMenuItem key={index}>
159
- {item.href ? (
160
- <Link href={item.href} onClick={item.onClick}>
161
- {item.name}
162
- </Link>
163
- ) : (
164
- <>{item.name}</>
165
- )}
166
- </DropdownMenuItem>
167
- ))}
168
+ {allItems.map((item, index) =>
169
+ item.href ? (
170
+ // The item renders AS the link, so the whole row navigates — a Link
171
+ // nested inside the row only answered taps on its own text.
172
+ <DropdownMenuItem
173
+ key={index}
174
+ className="min-h-10"
175
+ render={(props) => (
176
+ <Link
177
+ {...props}
178
+ href={item.href!}
179
+ className={cn(props.className, "text-foreground font-normal")}
180
+ onClick={(event: MouseEvent<HTMLAnchorElement>) => {
181
+ props.onClick?.(event);
182
+ item.onClick?.();
183
+ }}
184
+ >
185
+ {props.children}
186
+ </Link>
187
+ )}
188
+ >
189
+ {item.name}
190
+ </DropdownMenuItem>
191
+ ) : (
192
+ <DropdownMenuItem key={index} className="min-h-10">
193
+ {item.name}
194
+ </DropdownMenuItem>
195
+ ),
196
+ )}
168
197
  </DropdownMenuContent>
169
198
  </DropdownMenu>
170
199
  );
@@ -15,9 +15,22 @@ type HeaderProps = {
15
15
  /** Rendered before everything else, on mobile only — where the sidebar (and its logo) is off-canvas. */
16
16
  logo?: React.ReactNode;
17
17
  className?: string;
18
+ /**
19
+ * Drop the sidebar toggle on mobile. Set by page shells that render the mobile
20
+ * bottom bar, whose "Menu" slot opens the same drawer — two controls for one
21
+ * drawer only cost header room on a phone. Desktop always keeps the toggle.
22
+ */
23
+ hideSidebarTriggerOnMobile?: boolean;
18
24
  };
19
25
 
20
- export function Header({ children, mobileChildren, leftContent, logo, className }: HeaderProps) {
26
+ export function Header({
27
+ children,
28
+ mobileChildren,
29
+ leftContent,
30
+ logo,
31
+ className,
32
+ hideSidebarTriggerOnMobile,
33
+ }: HeaderProps) {
21
34
  const { breadcrumbs } = useSharedContext();
22
35
  const rootLabel = useHeaderRootLabel();
23
36
  const isRootHidden = useIsBreadcrumbRootHidden();
@@ -42,9 +55,9 @@ export function Header({ children, mobileChildren, leftContent, logo, className
42
55
  >
43
56
  <div className="bg-sidebar flex h-12 w-full flex-row items-center justify-between ps-2 pe-4">
44
57
  {isMobile && logo && <div className="flex shrink-0 flex-row items-center pe-1">{logo}</div>}
45
- {sidebar && <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />}
58
+ {sidebar && !(isMobile && hideSidebarTriggerOnMobile) && <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />}
46
59
  {leftContent}
47
- <div className="flex w-full flex-row items-center justify-start">
60
+ <div className="flex w-full flex-row items-center justify-start max-md:min-w-0">
48
61
  <BreadcrumbNavigation items={breadcrumbs} rootLabel={rootLabel ?? undefined} showRoot={!isRootHidden} />
49
62
  </div>
50
63
  {isMobile
@@ -84,6 +84,18 @@ export function MobileNavigationBar({ className }: { className?: string } = {})
84
84
  </>
85
85
  );
86
86
 
87
+ // A slot with neither href nor onClick is self-contained: its `render`
88
+ // brings its own control (e.g. an avatar that opens a user menu), so it
89
+ // is not wrapped in a Link/button — that would nest one interactive
90
+ // element inside another.
91
+ if (!item.href && !item.onClick) {
92
+ return (
93
+ <div key={item.key} data-testid={`mobile-nav-${item.key}`} className={cn(slotClass, tone)}>
94
+ <SlotContent item={item} />
95
+ </div>
96
+ );
97
+ }
98
+
87
99
  return item.href ? (
88
100
  <Link
89
101
  key={item.key}
@@ -109,6 +109,23 @@ describe("MobileNavigationBar", () => {
109
109
  expect(screen.getByTestId("avatar-stub")).toBeInTheDocument();
110
110
  });
111
111
 
112
+ it("renders a slot with neither href nor onClick as-is, without wrapping it in a link or button", () => {
113
+ renderBar([
114
+ {
115
+ key: "profile",
116
+ label: "Account",
117
+ render: (
118
+ <button type="button" data-testid="menu-trigger">
119
+ Account
120
+ </button>
121
+ ),
122
+ },
123
+ ]);
124
+ const trigger = screen.getByTestId("menu-trigger");
125
+ expect(trigger.parentElement?.closest("button, a")).toBeNull();
126
+ expect(screen.getAllByRole("button")).toHaveLength(1);
127
+ });
128
+
112
129
  // The load-bearing case: a bare startsWith would make href="/" match everything.
113
130
  it("marks only the exactly-matching slot active when one href is '/'", () => {
114
131
  pathnameRef.current = "/conversations";
@@ -10,6 +10,7 @@ import React, { ReactNode, memo, useMemo, useState } from "react";
10
10
  import { DataListRetriever, TableContent, useTableGenerator } from "../../hooks";
11
11
  import { ModuleWithPermissions } from "../../permissions";
12
12
  import { Button, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "../../shadcnui";
13
+ import { useIsMobile } from "../../utils/use-mobile";
13
14
  import { MicroLabel } from "../typography";
14
15
  import { ContentTableSearch } from "./ContentTableSearch";
15
16
 
@@ -110,6 +111,7 @@ function renderCell(cellDef: unknown, context: unknown): ReactNode {
110
111
  export const ContentListTable = memo(function ContentListTable(props: ContentListTableProps) {
111
112
  const { data, fields, checkedIds, toggleId, allowSearch, filters: _filters, fullWidth, onRowClick } = props;
112
113
  const t = useTranslations();
114
+ const isMobile = useIsMobile();
113
115
  const [expanded, setExpanded] = useState<ExpandedState>(
114
116
  props.defaultExpanded === true ? true : typeof props.defaultExpanded === "object" ? props.defaultExpanded : {},
115
117
  );
@@ -212,13 +214,60 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
212
214
  // 12px title is the mismatch the opt-in exists to avoid.
213
215
  const titleIsLarge = (props.titleSize ?? (fullWidth ? `lg` : `sm`)) === `lg`;
214
216
 
217
+ const titleLabel = (
218
+ <div
219
+ className={cn(
220
+ "text-muted-foreground flex items-center gap-x-2 font-light whitespace-nowrap",
221
+ titleIsLarge ? `text-lg` : `text-sm`,
222
+ )}
223
+ >
224
+ {props.titleActions}
225
+ {props.tableGeneratorType.icon && (
226
+ <props.tableGeneratorType.icon className={cn(`text-primary`, titleIsLarge ? `h-6 w-6` : `h-4 w-4`)} />
227
+ )}
228
+ {props.title}
229
+ </div>
230
+ );
231
+
232
+ const hasTitleControls = !!(props.functions || props.filters || allowSearch);
233
+ const titleControls = (
234
+ <>
235
+ {props.filters}
236
+ {props.functions}
237
+ {/* `allowSearch !== false`, not `allowSearch`: the search box has
238
+ always rendered here whenever `functions`/`filters` were set, and
239
+ most callers rely on that without passing the flag. Gating on the
240
+ flag alone (as the sibling ContentListGrid does) would silently
241
+ remove search from every such list. This only lets a caller whose
242
+ retriever ignores `search` — e.g. notifications, whose repository
243
+ has no search param and no fulltext index — opt out of a control
244
+ that would do nothing. */}
245
+ {allowSearch !== false && <ContentTableSearch data={data} />}
246
+ </>
247
+ );
248
+
215
249
  return (
216
250
  <div data-help="list.table" className="flex w-full flex-col">
217
251
  {/* <div className="overflow-clip rounded-md border"> */}
218
252
  <div className={cn(`overflow-clip`, fullWidth ? `` : `rounded-md border`)}>
253
+ {/* On a phone the title bar sits above the table instead of inside its
254
+ header: inside, it is as wide as the table, so on a table wider than
255
+ the screen "Create New", search and filters land off-screen. Above it,
256
+ it wraps within the card while the table scrolls sideways on its own. */}
257
+ {props.title && isMobile && (
258
+ <div
259
+ data-testid="content-list-table-title-bar"
260
+ className="bg-card text-primary flex flex-wrap items-center gap-2 border-b p-4 font-bold"
261
+ >
262
+ <div className="shrink-0">{titleLabel}</div>
263
+ {hasTitleControls && (
264
+ <div className="ms-auto flex flex-wrap items-center justify-end gap-2">{titleControls}</div>
265
+ )}
266
+ </div>
267
+ )}
219
268
  <Table>
220
269
  <TableHeader className="bg-muted font-semibold">
221
- {props.title && (
270
+ {props.title && !isMobile && (
222
271
  <TableRow>
223
272
  <TableHead
224
273
  className="bg-card rounded-t-lg text-primary p-4 text-start font-bold"
@@ -226,37 +275,8 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
226
275
  >
227
276
  <div className="flex w-full items-center justify-between gap-x-2">
228
277
  {/* <div className="w-full">{fullWidth ? `` : props.title}</div> */}
229
- <div className="w-full">
230
- <div
231
- className={cn(
232
- "text-muted-foreground flex items-center gap-x-2 font-light whitespace-nowrap",
233
- titleIsLarge ? `text-lg` : `text-sm`,
234
- )}
235
- >
236
- {props.titleActions}
237
- {props.tableGeneratorType.icon && (
238
- <props.tableGeneratorType.icon
239
- className={cn(`text-primary`, titleIsLarge ? `h-6 w-6` : `h-4 w-4`)}
240
- />
241
- )}
242
- {props.title}
243
- </div>
244
- </div>
245
- {(props.functions || props.filters || allowSearch) && (
246
- <>
247
- {props.filters}
248
- {props.functions}
249
- {/* `allowSearch !== false`, not `allowSearch`: the search box has
250
- always rendered here whenever `functions`/`filters` were set, and
251
- most callers rely on that without passing the flag. Gating on the
252
- flag alone (as the sibling ContentListGrid does) would silently
253
- remove search from every such list. This only lets a caller whose
254
- retriever ignores `search` — e.g. notifications, whose repository
255
- has no search param and no fulltext index — opt out of a control
256
- that would do nothing. */}
257
- {allowSearch !== false && <ContentTableSearch data={data} />}
258
- </>
259
- )}
278
+ <div className="w-full">{titleLabel}</div>
279
+ {hasTitleControls && titleControls}
260
280
  </div>
261
281
  </TableHead>
262
282
  </TableRow>
package/src/core/index.ts CHANGED
@@ -113,6 +113,10 @@ export * from "../features/tokenusage/tokenusage-admin.module";
113
113
  // rationale as the line above: a bootstrapper needs it, and it must not drag
114
114
  // the feature's recharts client bundle in.
115
115
  export * from "../features/tokenusage/tokenusage.modules";
116
+ // The analytics module bundle. Same placement rationale as the tokenusage lines
117
+ // above: a bootstrapper needs it, and it must not drag the feature's client
118
+ // bundle in.
119
+ export * from "../features/analytics/analytics.modules";
116
120
  // configureTokenUsage() is called from the app's bootstrap file, which is
117
121
  // evaluated on the server as well as the client. Reaching it through the
118
122
  // client-marked ./tokenusage barrel makes it a client function, and calling one
@@ -73,6 +73,13 @@ export interface FoundationModuleDefinitions {
73
73
  TokenUsageReportSummary: ModuleWithPermissions;
74
74
  TokenUsageReportTimeline: ModuleWithPermissions;
75
75
  TokenUsageReportBreakdown: ModuleWithPermissions;
76
+ // First-party web analytics modules (tracker + administrative dashboard)
77
+ AnalyticsEvent: ModuleWithPermissions;
78
+ AnalyticsSummary: ModuleWithPermissions;
79
+ AnalyticsTimeline: ModuleWithPermissions;
80
+ AnalyticsBreakdown: ModuleWithPermissions;
81
+ AnalyticsSession: ModuleWithPermissions;
82
+ AnalyticsPageView: ModuleWithPermissions;
76
83
  }
77
84
 
78
85
  // App-specific modules - apps will augment this interface ONLY
@@ -34,6 +34,7 @@ const DECLARED_FOR_APP_RENDERED_SURFACES = new Set<string>([
34
34
  "administration.token_usage.description",
35
35
  "administration.users.description",
36
36
  "administration.ai_connections.description",
37
+ "analytics.admin.description",
37
38
  "handbook.chat.delete_confirm",
38
39
  "handbook.chat.rename_placeholder",
39
40
  "handbook.contents.title",
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AnalyticsAdminPageContainer, AnalyticsJourneyPageContainer } from "../index";
3
+ import { AnalyticsAdminPage, AnalyticsJourneyPage } from "../server-entry";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ hasRole: vi.fn(),
7
+ redirect: vi.fn((url: string) => {
8
+ // next/navigation's redirect throws to stop rendering; mirror that.
9
+ throw new Error(`NEXT_REDIRECT:${url}`);
10
+ }),
11
+ }));
12
+
13
+ // ServerSession reads next/headers cookies, which only exist inside a request.
14
+ vi.mock("../../../server", () => ({
15
+ ServerSession: { hasRole: mocks.hasRole },
16
+ }));
17
+
18
+ vi.mock("next/navigation", () => ({
19
+ redirect: mocks.redirect,
20
+ }));
21
+
22
+ describe("analytics server entry", () => {
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ it("AnalyticsAdminPage renders the admin container for the role holder", async () => {
28
+ mocks.hasRole.mockResolvedValue(true);
29
+
30
+ const element = await AnalyticsAdminPage({
31
+ adminRoleId: "role-admin",
32
+ pageUrl: "/admin/stats",
33
+ sessionPageUrl: "/admin/stats/sessions",
34
+ });
35
+
36
+ expect(mocks.hasRole).toHaveBeenCalledWith("role-admin");
37
+ expect(element.type).toBe(AnalyticsAdminPageContainer);
38
+ expect(element.props).toEqual({ pageUrl: "/admin/stats", sessionPageUrl: "/admin/stats/sessions" });
39
+ });
40
+
41
+ it("AnalyticsAdminPage redirects to `/` by default without the role", async () => {
42
+ mocks.hasRole.mockResolvedValue(false);
43
+
44
+ await expect(AnalyticsAdminPage({ adminRoleId: "role-admin" })).rejects.toThrow("NEXT_REDIRECT:/");
45
+ expect(mocks.redirect).toHaveBeenCalledWith("/");
46
+ });
47
+
48
+ it("AnalyticsJourneyPage renders the journey container for the role holder", async () => {
49
+ mocks.hasRole.mockResolvedValue(true);
50
+
51
+ const element = await AnalyticsJourneyPage({ adminRoleId: "role-admin", sessionId: "s1", pageUrl: "/admin/stats" });
52
+
53
+ expect(element.type).toBe(AnalyticsJourneyPageContainer);
54
+ expect(element.props).toEqual({ sessionId: "s1", pageUrl: "/admin/stats" });
55
+ });
56
+
57
+ it("AnalyticsJourneyPage redirects to deniedRedirect without the role", async () => {
58
+ mocks.hasRole.mockResolvedValue(false);
59
+
60
+ await expect(
61
+ AnalyticsJourneyPage({ adminRoleId: "role-admin", sessionId: "s1", deniedRedirect: "/401" }),
62
+ ).rejects.toThrow("NEXT_REDIRECT:/401");
63
+ });
64
+ });
@@ -0,0 +1,30 @@
1
+ import { ModuleFactory, ModuleWithPermissions } from "../../permissions";
2
+ import { AnalyticsBreakdown } from "./data/analytics-breakdown";
3
+ import { AnalyticsEvent } from "./data/analytics-event";
4
+ import { AnalyticsPageView } from "./data/analytics-page-view";
5
+ import { AnalyticsSession } from "./data/analytics-session";
6
+ import { AnalyticsSummary } from "./data/analytics-summary";
7
+ import { AnalyticsTimeline } from "./data/analytics-timeline";
8
+
9
+ /**
10
+ * Every resource behind the analytics tracker and dashboard, as one object a
11
+ * consuming app spreads into its `allModules`. Same rationale as
12
+ * `tokenUsageModules`: one spread cannot be partially done, and a forgotten
13
+ * name is `undefined` at runtime even though `Modules.X` typechecks.
14
+ *
15
+ * `AnalyticsPageView` is only a child segment: the service builds
16
+ * `analytics/administration/sessions/{id}/pageviews` with EndpointCreator's
17
+ * `childEndpoint`. None has a pageUrl — these are not navigable resources.
18
+ *
19
+ * `satisfies` keeps the constraint without widening the return type to an
20
+ * index signature (see tokenusage.modules.ts).
21
+ */
22
+ export const analyticsModules = (factory: ModuleFactory) =>
23
+ ({
24
+ AnalyticsEvent: factory({ name: "analytics/events", model: AnalyticsEvent }),
25
+ AnalyticsSummary: factory({ name: "analytics/administration/summary", model: AnalyticsSummary }),
26
+ AnalyticsTimeline: factory({ name: "analytics/administration/timeline", model: AnalyticsTimeline }),
27
+ AnalyticsBreakdown: factory({ name: "analytics/administration/breakdown", model: AnalyticsBreakdown }),
28
+ AnalyticsSession: factory({ name: "analytics/administration/sessions", model: AnalyticsSession }),
29
+ AnalyticsPageView: factory({ name: "pageviews", model: AnalyticsPageView }),
30
+ }) satisfies Record<string, ModuleWithPermissions>;