@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
@@ -0,0 +1,246 @@
1
+ "use client";
2
+
3
+ import { useLocale, useTranslations } from "next-intl";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { DetailField, RoundPageContainer } from "../../../components";
6
+ import { SharedProvider } from "../../../contexts";
7
+ import { usePageUrlGenerator } from "../../../hooks";
8
+ import { BreadcrumbItemData } from "../../../interfaces";
9
+ import {
10
+ Badge,
11
+ Card,
12
+ CardContent,
13
+ CardHeader,
14
+ CardTitle,
15
+ Table,
16
+ TableBody,
17
+ TableCell,
18
+ TableHead,
19
+ TableHeader,
20
+ TableRow,
21
+ } from "../../../shadcnui";
22
+ import type { AnalyticsPageViewInterface } from "../data/analytics-page-view.interface";
23
+ import type { AnalyticsSessionInterface } from "../data/analytics-session.interface";
24
+ import { AnalyticsAdminService } from "../data/AnalyticsAdminService";
25
+ import { AnalyticsSessionsTable } from "./AnalyticsSessionsTable";
26
+
27
+ /** Default route of the dashboard the breadcrumb links back to. */
28
+ const ANALYTICS_ADMIN_PAGE_URL = "/administration/analytics";
29
+
30
+ /**
31
+ * How far back the visitor's other sessions are looked up: the 13-month raw
32
+ * retention, which sits inside the API's 400-day maximum range.
33
+ */
34
+ const OTHER_SESSIONS_WINDOW_DAYS = 395;
35
+
36
+ type Props = {
37
+ sessionId: string;
38
+ /** Route of the dashboard; the journey pages live under `${pageUrl}/sessions`. */
39
+ pageUrl?: string;
40
+ };
41
+
42
+ /**
43
+ * One session, followed page by page.
44
+ *
45
+ * The attribution header says where the visitor came from, the page-view table
46
+ * replays the session in order with the time spent before each step, and the
47
+ * visitor's other sessions close the page. Unlike the dashboard this page has no
48
+ * filters, so it fetches on mount without a provider and publishes its own
49
+ * breadcrumb back to the dashboard.
50
+ */
51
+ export function AnalyticsJourneyContainer({ sessionId, pageUrl = ANALYTICS_ADMIN_PAGE_URL }: Props) {
52
+ const t = useTranslations();
53
+ const locale = useLocale();
54
+ const generateUrl = usePageUrlGenerator();
55
+
56
+ const [session, setSession] = useState<AnalyticsSessionInterface | undefined>(undefined);
57
+ const [pageViews, setPageViews] = useState<AnalyticsPageViewInterface[]>([]);
58
+ const [otherSessions, setOtherSessions] = useState<AnalyticsSessionInterface[]>([]);
59
+ const [isLoading, setIsLoading] = useState(true);
60
+ const [error, setError] = useState<string | null>(null);
61
+
62
+ useEffect(() => {
63
+ // Out-of-order guard, as in AnalyticsAdminContext: a new sessionId must not
64
+ // be overwritten by the slower response for the previous one.
65
+ let cancelled = false;
66
+
67
+ setIsLoading(true);
68
+ setError(null);
69
+
70
+ Promise.all([
71
+ AnalyticsAdminService.getSession({ id: sessionId }),
72
+ AnalyticsAdminService.getSessionPageViews({ sessionId }),
73
+ ])
74
+ .then(async ([nextSession, nextPageViews]) => {
75
+ if (cancelled) return;
76
+ setSession(nextSession);
77
+ setPageViews(nextPageViews ?? []);
78
+
79
+ const to = new Date();
80
+ const from = new Date(to.getTime() - OTHER_SESSIONS_WINDOW_DAYS * 24 * 60 * 60 * 1000);
81
+ const visitorSessions = await AnalyticsAdminService.getSessions({
82
+ from: from.toISOString(),
83
+ to: to.toISOString(),
84
+ section: "all",
85
+ visitorId: nextSession.visitorId,
86
+ });
87
+ if (cancelled) return;
88
+ setOtherSessions((visitorSessions ?? []).filter((other) => other.id !== sessionId));
89
+ })
90
+ .catch((err) => {
91
+ if (cancelled) return;
92
+ console.error("Failed to load the analytics journey:", err);
93
+ setError(err instanceof Error ? err.message : String(err));
94
+ })
95
+ .finally(() => {
96
+ if (cancelled) return;
97
+ setIsLoading(false);
98
+ });
99
+
100
+ return () => {
101
+ cancelled = true;
102
+ };
103
+ }, [sessionId]);
104
+
105
+ const dateTime = useMemo(
106
+ () => new Intl.DateTimeFormat(locale, { dateStyle: "short", timeStyle: "medium" }),
107
+ [locale],
108
+ );
109
+
110
+ /** Time since the previous page view, in the largest whole unit that fits. */
111
+ const formatGap = (milliseconds: number): string => {
112
+ const seconds = Math.max(0, Math.round(milliseconds / 1000));
113
+ if (seconds < 60) return unit(locale, "second", seconds);
114
+ if (seconds < 3600) return unit(locale, "minute", Math.round(seconds / 60));
115
+ return unit(locale, "hour", Math.round(seconds / 3600));
116
+ };
117
+
118
+ const breadcrumb: BreadcrumbItemData[] = [
119
+ { name: t("analytics.admin.title"), href: generateUrl({ page: pageUrl }) },
120
+ { name: t("analytics.admin.panels.journey") },
121
+ ];
122
+
123
+ const title = { type: t("analytics.admin.panels.journey") };
124
+
125
+ const renderBody = () => {
126
+ if (error) {
127
+ return (
128
+ <RoundPageContainer fullWidth forceHeader>
129
+ <div className="p-4">
130
+ <Card>
131
+ <CardContent>
132
+ <p className="text-destructive text-xs/relaxed">{error}</p>
133
+ </CardContent>
134
+ </Card>
135
+ </div>
136
+ </RoundPageContainer>
137
+ );
138
+ }
139
+
140
+ // Loading renders nothing in the body, as on the dashboard.
141
+ if (isLoading || !session) return <RoundPageContainer fullWidth forceHeader />;
142
+
143
+ const source = [session.utmSource ?? session.referrerHost, session.utmMedium, session.utmCampaign]
144
+ .filter(Boolean)
145
+ .join(" / ");
146
+
147
+ return (
148
+ <RoundPageContainer fullWidth forceHeader>
149
+ <div className="flex w-full flex-col gap-4 p-4">
150
+ <Card data-testid="analytics-journey-attribution">
151
+ <CardContent className="flex flex-col gap-3">
152
+ <div className="flex flex-wrap items-center gap-2">
153
+ {session.user ? (
154
+ <span className="flex flex-col">
155
+ <span className="text-sm">{session.user.name}</span>
156
+ <span className="text-muted-foreground text-xs">{session.user.email}</span>
157
+ </span>
158
+ ) : (
159
+ <Badge variant="softGray">{t("analytics.admin.anonymous")}</Badge>
160
+ )}
161
+ {session.consented ? (
162
+ <Badge variant="softGreen">{t("analytics.admin.consented")}</Badge>
163
+ ) : (
164
+ <Badge variant="softGray">{t("analytics.admin.not_consented")}</Badge>
165
+ )}
166
+ </div>
167
+ <div className="grid gap-3 sm:grid-cols-3">
168
+ <DetailField label={t("analytics.admin.columns.started")} value={dateTime.format(session.startedAt)} />
169
+ <DetailField label={t("analytics.admin.columns.source")} value={source} />
170
+ <DetailField label={t("analytics.admin.columns.landing")} value={session.landingRoute} />
171
+ <DetailField label={t("analytics.admin.columns.device")} value={session.deviceType} />
172
+ <DetailField
173
+ label={t("analytics.admin.columns.visitor")}
174
+ value={<span className="tabular-nums">{session.visitorId}</span>}
175
+ />
176
+ </div>
177
+ </CardContent>
178
+ </Card>
179
+
180
+ <Card>
181
+ <CardHeader>
182
+ <CardTitle>{t("analytics.admin.columns.pages")}</CardTitle>
183
+ </CardHeader>
184
+ <CardContent>
185
+ {pageViews.length === 0 ? (
186
+ <p className="text-muted-foreground text-sm">{t("analytics.admin.no_data")}</p>
187
+ ) : (
188
+ // The package's <Table> wraps itself in an `overflow-x-clip`
189
+ // container — hence the child override, so wide rows scroll.
190
+ <div className="overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible">
191
+ <Table>
192
+ <TableHeader>
193
+ <TableRow>
194
+ <TableHead className="text-xs font-medium">{t("analytics.admin.columns.time")}</TableHead>
195
+ <TableHead className="text-end text-xs font-medium">
196
+ {t("analytics.admin.columns.gap")}
197
+ </TableHead>
198
+ <TableHead className="text-xs font-medium">{t("analytics.admin.columns.path")}</TableHead>
199
+ </TableRow>
200
+ </TableHeader>
201
+ <TableBody>
202
+ {pageViews.map((pageView, index) => {
203
+ const previous = index > 0 ? pageViews[index - 1] : undefined;
204
+ return (
205
+ <TableRow key={pageView.id} data-testid={`analytics-pageview-row-${pageView.id}`}>
206
+ <TableCell className="text-xs tabular-nums">
207
+ {dateTime.format(pageView.createdAt)}
208
+ </TableCell>
209
+ <TableCell className="text-end text-xs tabular-nums">
210
+ {previous ? formatGap(pageView.createdAt.getTime() - previous.createdAt.getTime()) : "—"}
211
+ </TableCell>
212
+ <TableCell className="text-xs">{pageView.path}</TableCell>
213
+ </TableRow>
214
+ );
215
+ })}
216
+ </TableBody>
217
+ </Table>
218
+ </div>
219
+ )}
220
+ </CardContent>
221
+ </Card>
222
+
223
+ <Card>
224
+ <CardHeader>
225
+ <CardTitle>{t("analytics.admin.panels.other_sessions")}</CardTitle>
226
+ </CardHeader>
227
+ <CardContent>
228
+ <AnalyticsSessionsTable
229
+ rows={otherSessions}
230
+ sessionPageUrl={`${pageUrl}/sessions`}
231
+ onLoadMore={() => undefined}
232
+ hasMore={false}
233
+ />
234
+ </CardContent>
235
+ </Card>
236
+ </div>
237
+ </RoundPageContainer>
238
+ );
239
+ };
240
+
241
+ return <SharedProvider value={{ breadcrumbs: breadcrumb, title }}>{renderBody()}</SharedProvider>;
242
+ }
243
+
244
+ function unit(locale: string, name: "second" | "minute" | "hour", value: number): string {
245
+ return new Intl.NumberFormat(locale, { style: "unit", unit: name, unitDisplay: "short" }).format(value);
246
+ }
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import { AnalyticsJourneyContainer } from "./AnalyticsJourneyContainer";
4
+
5
+ type Props = {
6
+ sessionId: string;
7
+ /** Route the breadcrumb links back to. Defaults to the container's default. */
8
+ pageUrl?: string;
9
+ };
10
+
11
+ /** The single-session journey page as one client component. */
12
+ export function AnalyticsJourneyPageContainer({ sessionId, pageUrl }: Props) {
13
+ return <AnalyticsJourneyContainer sessionId={sessionId} pageUrl={pageUrl} />;
14
+ }
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ import { useLocale, useTranslations } from "next-intl";
4
+ import { useMemo } from "react";
5
+ import { usePageUrlGenerator } from "../../../hooks";
6
+ import { Badge, Button, Link, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../shadcnui";
7
+ import { useUsageFormatters } from "../../tokenusage/lib/formatters";
8
+ import type { AnalyticsSessionInterface } from "../data/analytics-session.interface";
9
+
10
+ type Props = {
11
+ rows: AnalyticsSessionInterface[];
12
+ /** Base route of the journey page; each row links to `${sessionPageUrl}/${id}`. */
13
+ sessionPageUrl: string;
14
+ onLoadMore: () => void;
15
+ hasMore: boolean;
16
+ };
17
+
18
+ /** How many trailing characters of the visitor id identify an anonymous visitor. */
19
+ const VISITOR_SUFFIX_LENGTH = 6;
20
+
21
+ /**
22
+ * The sessions in the selected window, newest first as the backend returns them.
23
+ *
24
+ * Paginated by cursor: the owning context holds the next-page reference and
25
+ * appends on `onLoadMore`, so this table stays stateless. A session linked to a
26
+ * signed-in user shows the user; any other shows a short visitor suffix, enough
27
+ * to tell two anonymous visitors apart without displaying the whole id.
28
+ */
29
+ export function AnalyticsSessionsTable({ rows, sessionPageUrl, onLoadMore, hasMore }: Props) {
30
+ const t = useTranslations();
31
+ const locale = useLocale();
32
+ const generateUrl = usePageUrlGenerator();
33
+ const { decimal } = useUsageFormatters();
34
+
35
+ const dateTime = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "short", timeStyle: "short" }), [locale]);
36
+
37
+ if (rows.length === 0) {
38
+ return <p className="text-muted-foreground text-sm">{t("analytics.admin.no_data")}</p>;
39
+ }
40
+
41
+ return (
42
+ <div className="flex flex-col gap-3">
43
+ {/* The package's <Table> wraps itself in an `overflow-x-clip` container,
44
+ which would swallow the overflow before this scroller ever saw it —
45
+ hence the child override. Wide tables must scroll, never widen the page. */}
46
+ <div className="overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible">
47
+ <Table>
48
+ <TableHeader>
49
+ <TableRow>
50
+ <TableHead className="text-xs font-medium">{t("analytics.admin.columns.started")}</TableHead>
51
+ <TableHead className="text-xs font-medium">{t("analytics.admin.columns.visitor")}</TableHead>
52
+ <TableHead className="text-xs font-medium">{t("analytics.admin.columns.source")}</TableHead>
53
+ <TableHead className="text-xs font-medium">{t("analytics.admin.columns.landing")}</TableHead>
54
+ <TableHead className="text-end text-xs font-medium">{t("analytics.admin.columns.pages")}</TableHead>
55
+ <TableHead className="text-xs font-medium">{t("analytics.admin.columns.device")}</TableHead>
56
+ </TableRow>
57
+ </TableHeader>
58
+ <TableBody>
59
+ {rows.map((row) => {
60
+ const source = [row.utmSource ?? row.referrerHost, row.utmMedium].filter(Boolean).join(" / ");
61
+
62
+ return (
63
+ <TableRow key={row.id} data-testid={`analytics-session-row-${row.id}`}>
64
+ <TableCell className="text-xs tabular-nums">
65
+ <Link
66
+ href={generateUrl({ page: sessionPageUrl, id: row.id })}
67
+ data-testid={`analytics-session-link-${row.id}`}
68
+ >
69
+ {dateTime.format(row.startedAt)}
70
+ </Link>
71
+ </TableCell>
72
+ <TableCell className="text-xs">
73
+ {row.user ? (
74
+ <span className="flex flex-col">
75
+ <span>{row.user.name}</span>
76
+ <span className="text-muted-foreground text-xs">{row.user.email}</span>
77
+ </span>
78
+ ) : (
79
+ <span className="flex items-center gap-2">
80
+ <Badge variant="softGray">{t("analytics.admin.anonymous")}</Badge>
81
+ <span className="text-muted-foreground text-xs tabular-nums">
82
+ {row.visitorId.slice(-VISITOR_SUFFIX_LENGTH)}
83
+ </span>
84
+ </span>
85
+ )}
86
+ </TableCell>
87
+ <TableCell className="text-xs">{source}</TableCell>
88
+ <TableCell className="text-xs">{row.landingRoute}</TableCell>
89
+ <TableCell className="text-end text-xs tabular-nums">{decimal(row.pageViews, 0)}</TableCell>
90
+ <TableCell className="text-muted-foreground text-xs">{row.deviceType}</TableCell>
91
+ </TableRow>
92
+ );
93
+ })}
94
+ </TableBody>
95
+ </Table>
96
+ </div>
97
+
98
+ {hasMore && (
99
+ <div className="flex justify-center">
100
+ <Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
101
+ {t("analytics.admin.load_more")}
102
+ </Button>
103
+ </div>
104
+ )}
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useTheme } from "next-themes";
5
+ import { useMemo } from "react";
6
+ import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
7
+ import {
8
+ ChartConfig,
9
+ ChartContainer,
10
+ ChartLegend,
11
+ ChartLegendContent,
12
+ ChartTooltip,
13
+ ChartTooltipContent,
14
+ } from "../../../shadcnui";
15
+ import { useUsageFormatters } from "../../tokenusage/lib/formatters";
16
+ import { ChartMode, seriesColor } from "../../tokenusage/lib/palette";
17
+ import { AnalyticsTimelineInterface } from "../data/analytics-timeline.interface";
18
+
19
+ /** The two fixed series, in palette order: slot 0 visitors, slot 1 page views. */
20
+ const SERIES = ["visitors", "pageViews"] as const;
21
+
22
+ type AnalyticsTimelineChartProps = {
23
+ rows: AnalyticsTimelineInterface[];
24
+ className?: string;
25
+ };
26
+
27
+ type TimelineBucket = { bucket: string; visitors: number; pageViews: number };
28
+
29
+ /**
30
+ * The bucket key, derived with UTC getters.
31
+ *
32
+ * The backend field is `type: "date"` — a calendar day with no time — and the
33
+ * model parses the `YYYY-MM-DD` wire value with `new Date(...)`, which lands on
34
+ * UTC midnight. Reading it back with local getters would shift the bucket a day
35
+ * early west of UTC, so the key (and every label built from it) stays in UTC.
36
+ */
37
+ const bucketKey = (date: Date): string => {
38
+ const y = date.getUTCFullYear();
39
+ const m = `${date.getUTCMonth() + 1}`.padStart(2, "0");
40
+ const d = `${date.getUTCDate()}`.padStart(2, "0");
41
+ return `${y}-${m}-${d}`;
42
+ };
43
+
44
+ /**
45
+ * The granularity the data was bucketed at, inferred from the spacing between
46
+ * consecutive buckets. The backend chooses the granularity and the rows carry
47
+ * the consequence, so the axis reads it off the data rather than taking a prop
48
+ * that could disagree with what was actually fetched.
49
+ */
50
+ const inferGranularity = (buckets: string[]): "day" | "week" | "month" => {
51
+ if (buckets.length < 2) return "day";
52
+
53
+ const dayMs = 24 * 60 * 60 * 1000;
54
+ let smallestGap = Number.POSITIVE_INFINITY;
55
+ for (let i = 1; i < buckets.length; i += 1) {
56
+ const gap = (Date.parse(buckets[i]) - Date.parse(buckets[i - 1])) / dayMs;
57
+ if (gap > 0 && gap < smallestGap) smallestGap = gap;
58
+ }
59
+
60
+ if (smallestGap >= 28) return "month";
61
+ if (smallestGap >= 7) return "week";
62
+ return "day";
63
+ };
64
+
65
+ /**
66
+ * Traffic over time as a grouped bar chart: visitors and page views per bucket.
67
+ *
68
+ * The backend returns one row per bucket per section; the chart sums the
69
+ * sections into one entry per bucket. The two series measure different things,
70
+ * so they sit side by side rather than stacked.
71
+ *
72
+ * Colour does an IDENTITY job here — each bar is a series, not a magnitude — so
73
+ * it draws the fixed categorical order from the token-usage palette (slots 0 and
74
+ * 1). The palette's light-mode validator run carries a sub-3:1 contrast WARN,
75
+ * which obliges a relief channel: the legend and the value-carrying tooltip
76
+ * below are what make the fills readable.
77
+ */
78
+ export function AnalyticsTimelineChart({ rows, className }: AnalyticsTimelineChartProps) {
79
+ const t = useTranslations();
80
+ const { compact, bucketDate, decimal } = useUsageFormatters();
81
+ const { resolvedTheme } = useTheme();
82
+ const mode: ChartMode = resolvedTheme === "dark" ? "dark" : "light";
83
+
84
+ const chartData = useMemo(() => {
85
+ const byBucket = new Map<string, TimelineBucket>();
86
+ for (const row of rows) {
87
+ const key = bucketKey(row.bucket);
88
+ let entry = byBucket.get(key);
89
+ if (!entry) {
90
+ entry = { bucket: key, visitors: 0, pageViews: 0 };
91
+ byBucket.set(key, entry);
92
+ }
93
+ entry.visitors += row.visitors;
94
+ entry.pageViews += row.pageViews;
95
+ }
96
+ return [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
97
+ }, [rows]);
98
+
99
+ const chartConfig = useMemo(
100
+ () =>
101
+ ({
102
+ visitors: { label: t("analytics.admin.series.visitors") },
103
+ pageViews: { label: t("analytics.admin.series.page_views") },
104
+ }) as ChartConfig,
105
+ [t],
106
+ );
107
+
108
+ if (chartData.length === 0) {
109
+ return (
110
+ <p className={className ? `text-muted-foreground text-sm ${className}` : "text-muted-foreground text-sm"}>
111
+ {t("analytics.admin.no_data")}
112
+ </p>
113
+ );
114
+ }
115
+
116
+ const granularity = inferGranularity(chartData.map((entry) => entry.bucket));
117
+
118
+ return (
119
+ // The chart is a coordinate system, not prose: recharts lays the axes and the
120
+ // bars out left-to-right, so the whole chart stays an LTR island even under
121
+ // dir="rtl". Only the labels inside it are translated.
122
+ // rtl-ok: deliberate LTR island (chart)
123
+ <div className={className} dir="ltr">
124
+ {/* The pivot, exposed for assertions and for screen readers that would
125
+ otherwise get nothing from the SVG. */}
126
+ <span className="sr-only" data-testid="analytics-timeline-data">
127
+ {JSON.stringify(chartData)}
128
+ </span>
129
+
130
+ <ChartContainer config={chartConfig} className="aspect-auto h-72 w-full">
131
+ <BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
132
+ <CartesianGrid vertical={false} />
133
+ <XAxis
134
+ dataKey="bucket"
135
+ tickLine={false}
136
+ axisLine={false}
137
+ tickMargin={8}
138
+ minTickGap={16}
139
+ tickFormatter={(value: string) => bucketDate(value, granularity)}
140
+ />
141
+ <YAxis
142
+ tickLine={false}
143
+ axisLine={false}
144
+ tickMargin={8}
145
+ width={48}
146
+ tickFormatter={(value: number) => compact(value)}
147
+ />
148
+ <ChartTooltip
149
+ content={
150
+ <ChartTooltipContent
151
+ labelFormatter={(value) => bucketDate(String(value), granularity)}
152
+ valueFormatter={(value) => decimal(Number(value), 0)}
153
+ />
154
+ }
155
+ />
156
+ <ChartLegend content={<ChartLegendContent />} />
157
+ {SERIES.map((series, index) => (
158
+ <Bar
159
+ key={series}
160
+ dataKey={series}
161
+ maxBarSize={28}
162
+ fill={seriesColor(index, mode)}
163
+ // A 2px stroke in the surface colour is the gap between
164
+ // neighbouring bars — the palette's own secondary-encoding
165
+ // channel, not a border.
166
+ stroke="var(--background)"
167
+ strokeWidth={2}
168
+ radius={[4, 4, 0, 0]}
169
+ />
170
+ ))}
171
+ </BarChart>
172
+ </ChartContainer>
173
+ </div>
174
+ );
175
+ }
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import { useEffect, useRef } from "react";
5
+ import { v4 } from "uuid";
6
+ import { AnalyticsService } from "../data/AnalyticsService";
7
+ import type { AnalyticsEventInput } from "../data/analytics.types";
8
+ import { readUtm } from "../lib/utm";
9
+ import { clearVisitorCookie, ensureVisitorCookie } from "../lib/visitorCookie";
10
+
11
+ // The dashboard itself is never tracked, so administrators reading it do not inflate it.
12
+ const EXCLUDED_PREFIX = "/administration/analytics";
13
+
14
+ // The API rejects longer referrers.
15
+ const MAX_REFERRER_LENGTH = 2048;
16
+
17
+ type Attribution = Pick<
18
+ AnalyticsEventInput,
19
+ "referrer" | "utmSource" | "utmMedium" | "utmCampaign" | "utmTerm" | "utmContent"
20
+ >;
21
+
22
+ type AnalyticsTrackerProps = {
23
+ hasConsent: boolean;
24
+ resolveSection: (pathname: string) => "public" | "app";
25
+ sensitivePrefixes?: string[];
26
+ };
27
+
28
+ /**
29
+ * Returns the referrer only when it comes from another host, so internal
30
+ * navigation is never stored as a traffic source.
31
+ */
32
+ function readExternalReferrer(): string | undefined {
33
+ if (!document.referrer) return undefined;
34
+ try {
35
+ if (new URL(document.referrer).host === window.location.host) return undefined;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ return document.referrer.slice(0, MAX_REFERRER_LENGTH);
40
+ }
41
+
42
+ /**
43
+ * Replaces every segment after a sensitive prefix with ":code", so single-use
44
+ * account codes in the URL never leave the browser.
45
+ */
46
+ function maskSensitivePath(path: string, sensitivePrefixes: string[] | undefined): string {
47
+ if (!sensitivePrefixes) return path;
48
+
49
+ for (const prefix of sensitivePrefixes) {
50
+ if (path === prefix) return path;
51
+ if (path.startsWith(`${prefix}/`)) {
52
+ const segments = path
53
+ .slice(prefix.length + 1)
54
+ .split("/")
55
+ .filter((segment) => segment.length > 0);
56
+ return [prefix, ...segments.map(() => ":code")].join("/");
57
+ }
58
+ }
59
+
60
+ return path;
61
+ }
62
+
63
+ export function AnalyticsTracker({ hasConsent, resolveSection, sensitivePrefixes }: AnalyticsTrackerProps): null {
64
+ const pathname = usePathname();
65
+ const lastPath = useRef<string | null>(null);
66
+ const lastConsent = useRef<boolean>(false);
67
+ const attribution = useRef<Attribution | null>(null);
68
+
69
+ useEffect(() => {
70
+ if (!hasConsent) clearVisitorCookie();
71
+
72
+ if (!pathname) return;
73
+ if (typeof navigator !== "undefined" && navigator.webdriver) return;
74
+
75
+ const path = pathname.split(/[?#]/)[0] || "/";
76
+ if (path.startsWith(EXCLUDED_PREFIX)) return;
77
+
78
+ // One event per distinct path. The same path is sent again only when consent
79
+ // was just granted, so the processor starts a new visitor on the cookie id.
80
+ const isNewPath = lastPath.current !== path;
81
+ const consentJustGranted = hasConsent && !lastConsent.current;
82
+ lastConsent.current = hasConsent;
83
+ if (!isNewPath && !consentJustGranted) return;
84
+ lastPath.current = path;
85
+
86
+ const sentPath = maskSensitivePath(path, sensitivePrefixes);
87
+
88
+ const input: AnalyticsEventInput = {
89
+ id: v4(),
90
+ path: sentPath,
91
+ section: resolveSection(sentPath),
92
+ screenWidth: window.innerWidth,
93
+ };
94
+
95
+ if (hasConsent) input.visitorId = ensureVisitorCookie();
96
+
97
+ // The first event of the page load carries the attribution. The event sent
98
+ // because consent was just granted carries it again, so the session opened
99
+ // on the cookie id keeps the source the visitor arrived from.
100
+ const isFirstEvent = attribution.current === null;
101
+ if (isFirstEvent) {
102
+ const referrer = readExternalReferrer();
103
+ attribution.current = { ...(referrer ? { referrer } : {}), ...readUtm(window.location.search) };
104
+ }
105
+ if (isFirstEvent || consentJustGranted) Object.assign(input, attribution.current);
106
+
107
+ // AnalyticsService.track never rejects: it logs and swallows its own failures.
108
+ void AnalyticsService.track(input);
109
+ }, [pathname, hasConsent, resolveSection, sensitivePrefixes]);
110
+
111
+ return null;
112
+ }