@carlonicora/nextjs-jsonapi 3.24.2 → 3.25.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 (178) 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-65HR6ULV.mjs → BlockNoteEditor-46O3JC4Z.mjs} +5 -5
  4. package/dist/{BlockNoteEditor-35D4R7HJ.js → BlockNoteEditor-HKS6AXDA.js} +20 -20
  5. package/dist/{BlockNoteEditor-35D4R7HJ.js.map → BlockNoteEditor-HKS6AXDA.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-QDXDVYEM.js → BlockNoteViewer-7BFNTN4E.js} +4 -4
  7. package/dist/{BlockNoteViewer-QDXDVYEM.js.map → BlockNoteViewer-7BFNTN4E.js.map} +1 -1
  8. package/dist/{BlockNoteViewer-KDKGUIG5.mjs → BlockNoteViewer-KRTAFTUX.mjs} +3 -3
  9. package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-Ck1wZJ81.d.ts} +1 -1
  10. package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-DgYH--Ik.d.mts} +1 -1
  11. package/dist/JsonApiRequest-TE5WD3WS.js +26 -0
  12. package/dist/{JsonApiRequest-G625QAH5.js.map → JsonApiRequest-TE5WD3WS.js.map} +1 -1
  13. package/dist/{JsonApiRequest-RRMNV7YE.mjs → JsonApiRequest-UHOWNI3O.mjs} +2 -2
  14. package/dist/analytics.modules-CfczU12P.d.ts +25 -0
  15. package/dist/analytics.modules-pzGwMASI.d.mts +25 -0
  16. package/dist/{auth.interface-BC1jp_ni.d.ts → auth.interface-BzI4onI_.d.ts} +1 -1
  17. package/dist/{auth.interface-dR2-6gPM.d.mts → auth.interface-Df4vXbNz.d.mts} +1 -1
  18. package/dist/billing/index.js +402 -402
  19. package/dist/billing/index.mjs +4 -4
  20. package/dist/{chunk-YXRVPEEJ.js → chunk-26MQIJNB.js} +1 -1
  21. package/dist/{chunk-YXRVPEEJ.js.map → chunk-26MQIJNB.js.map} +1 -1
  22. package/dist/{chunk-T76WLDE5.mjs → chunk-37DMFVLN.mjs} +1 -1
  23. package/dist/chunk-4Z2UONN2.mjs +110 -0
  24. package/dist/chunk-4Z2UONN2.mjs.map +1 -0
  25. package/dist/{chunk-3AR4KA5D.js → chunk-4Z45NYPR.js} +13 -13
  26. package/dist/{chunk-3AR4KA5D.js.map → chunk-4Z45NYPR.js.map} +1 -1
  27. package/dist/chunk-7B4E42R5.mjs +280 -0
  28. package/dist/chunk-7B4E42R5.mjs.map +1 -0
  29. package/dist/{chunk-U7QVISQS.js → chunk-CAKOFJZD.js} +450 -40
  30. package/dist/chunk-CAKOFJZD.js.map +1 -0
  31. package/dist/{chunk-IBVKPLHT.mjs → chunk-E3I4MT53.mjs} +422 -12
  32. package/dist/chunk-E3I4MT53.mjs.map +1 -0
  33. package/dist/chunk-GHFMKH3M.js +280 -0
  34. package/dist/chunk-GHFMKH3M.js.map +1 -0
  35. package/dist/chunk-KLV6RLDE.js +110 -0
  36. package/dist/chunk-KLV6RLDE.js.map +1 -0
  37. package/dist/{chunk-ZJI5ZZW5.mjs → chunk-Q6J2ICKO.mjs} +3 -3
  38. package/dist/{chunk-4O7R2Z2O.mjs → chunk-XDYILJ2S.mjs} +1749 -1737
  39. package/dist/{chunk-4O7R2Z2O.mjs.map → chunk-XDYILJ2S.mjs.map} +1 -1
  40. package/dist/{chunk-2XCUAHQT.js → chunk-Y3OKEQGL.js} +1126 -1114
  41. package/dist/chunk-Y3OKEQGL.js.map +1 -0
  42. package/dist/client/index.d.mts +6 -5
  43. package/dist/client/index.d.ts +6 -5
  44. package/dist/client/index.js +5 -5
  45. package/dist/client/index.js.map +1 -1
  46. package/dist/client/index.mjs +4 -4
  47. package/dist/components/index.d.mts +17 -7
  48. package/dist/components/index.d.ts +17 -7
  49. package/dist/components/index.js +5 -5
  50. package/dist/components/index.mjs +4 -4
  51. package/dist/{config-B6UB7j69.d.ts → config-3N1LSrcC.d.ts} +1 -1
  52. package/dist/{config-Cd5xTEfZ.d.ts → config-AVtuvfM1.d.ts} +1 -1
  53. package/dist/{config-n-ZpPmOL.d.mts → config-BSKTYDoh.d.mts} +1 -1
  54. package/dist/{config-YyhFHxkU.d.mts → config-BmufyEj6.d.mts} +1 -1
  55. package/dist/contexts/index.d.mts +5 -4
  56. package/dist/contexts/index.d.ts +5 -4
  57. package/dist/contexts/index.js +5 -5
  58. package/dist/contexts/index.mjs +4 -4
  59. package/dist/core/index.d.mts +31 -14
  60. package/dist/core/index.d.ts +31 -14
  61. package/dist/core/index.js +9 -5
  62. package/dist/core/index.js.map +1 -1
  63. package/dist/core/index.mjs +8 -4
  64. package/dist/features/analytics/index.css +30 -0
  65. package/dist/features/analytics/index.css.map +1 -0
  66. package/dist/features/analytics/index.d.mts +508 -0
  67. package/dist/features/analytics/index.d.ts +508 -0
  68. package/dist/features/analytics/index.js +1166 -0
  69. package/dist/features/analytics/index.js.map +1 -0
  70. package/dist/features/analytics/index.mjs +1166 -0
  71. package/dist/features/analytics/index.mjs.map +1 -0
  72. package/dist/features/analytics/server.d.mts +28 -0
  73. package/dist/features/analytics/server.d.ts +28 -0
  74. package/dist/features/analytics/server.js +41 -0
  75. package/dist/features/analytics/server.js.map +1 -0
  76. package/dist/features/analytics/server.mjs +41 -0
  77. package/dist/features/analytics/server.mjs.map +1 -0
  78. package/dist/features/help/index.d.mts +2 -2
  79. package/dist/features/help/index.d.ts +2 -2
  80. package/dist/features/help/index.js +38 -38
  81. package/dist/features/help/index.mjs +4 -4
  82. package/dist/features/tokenusage/index.d.mts +2 -2
  83. package/dist/features/tokenusage/index.d.ts +2 -2
  84. package/dist/features/tokenusage/index.js +139 -221
  85. package/dist/features/tokenusage/index.js.map +1 -1
  86. package/dist/features/tokenusage/index.mjs +26 -108
  87. package/dist/features/tokenusage/index.mjs.map +1 -1
  88. package/dist/index.d.mts +11 -9
  89. package/dist/index.d.ts +11 -9
  90. package/dist/index.js +10 -6
  91. package/dist/index.js.map +1 -1
  92. package/dist/index.mjs +9 -5
  93. package/dist/notification.interface-BNCvnVCW.d.ts +34 -0
  94. package/dist/notification.interface-P_pNfzZQ.d.mts +34 -0
  95. package/dist/{s3.service-Bqr-fQmi.d.ts → s3.service-CbUa6a-8.d.ts} +3 -2
  96. package/dist/{s3.service-Buiy1ncj.d.mts → s3.service-DAp8FbuG.d.mts} +3 -2
  97. package/dist/server/index.d.mts +5 -4
  98. package/dist/server/index.d.ts +5 -4
  99. package/dist/server/index.js +39 -279
  100. package/dist/server/index.js.map +1 -1
  101. package/dist/server/index.mjs +20 -260
  102. package/dist/server/index.mjs.map +1 -1
  103. package/dist/{types-bsm8Fp55.d.ts → types-Cn153-mH.d.ts} +1 -1
  104. package/dist/{types-Bq_UA8Lg.d.mts → types-D0uLYV2P.d.mts} +1 -1
  105. package/dist/{usePageUrlGenerator-J_ECv-oQ.d.ts → usePageUrlGenerator-B-3FRKBD.d.ts} +1 -1
  106. package/dist/{usePageUrlGenerator-uKBjAKqC.d.mts → usePageUrlGenerator-CCJ_cdQy.d.mts} +1 -1
  107. package/dist/{useSocket-BcH7l7B8.d.mts → useSocket-B9gTbuCj.d.mts} +1 -1
  108. package/dist/{useSocket-DCZd_eJk.d.ts → useSocket-BHVIyigI.d.ts} +1 -1
  109. package/dist/{notification.interface-MnRt1Tjm.d.mts → user.interface-CSaGWFbg.d.mts} +1 -31
  110. package/dist/{notification.interface-CjzV_KII.d.ts → user.interface-CrmwJLYW.d.ts} +1 -31
  111. package/package.json +11 -1
  112. package/src/client/index.ts +2 -0
  113. package/src/core/index.ts +4 -0
  114. package/src/core/registry/ModuleRegistry.ts +7 -0
  115. package/src/features/__tests__/i18n-keys.contract.spec.ts +1 -0
  116. package/src/features/analytics/__tests__/server-entry.spec.tsx +64 -0
  117. package/src/features/analytics/analytics.modules.ts +30 -0
  118. package/src/features/analytics/components/AnalyticsAdminContainer.tsx +97 -0
  119. package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +117 -0
  120. package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +25 -0
  121. package/src/features/analytics/components/AnalyticsAdminTiles.tsx +191 -0
  122. package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +104 -0
  123. package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +246 -0
  124. package/src/features/analytics/components/AnalyticsJourneyPageContainer.tsx +14 -0
  125. package/src/features/analytics/components/AnalyticsSessionsTable.tsx +107 -0
  126. package/src/features/analytics/components/AnalyticsTimelineChart.tsx +175 -0
  127. package/src/features/analytics/components/AnalyticsTracker.tsx +112 -0
  128. package/src/features/analytics/components/__tests__/AnalyticsAdminTiles.spec.tsx +39 -0
  129. package/src/features/analytics/components/__tests__/AnalyticsBreakdownTable.spec.tsx +39 -0
  130. package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +124 -0
  131. package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +117 -0
  132. package/src/features/analytics/components/__tests__/AnalyticsSessionsTable.spec.tsx +121 -0
  133. package/src/features/analytics/components/__tests__/AnalyticsTimelineChart.spec.tsx +35 -0
  134. package/src/features/analytics/components/__tests__/AnalyticsTracker.spec.tsx +248 -0
  135. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +28 -0
  136. package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +25 -0
  137. package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +273 -0
  138. package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +140 -0
  139. package/src/features/analytics/data/AnalyticsAdminService.ts +91 -0
  140. package/src/features/analytics/data/AnalyticsService.ts +23 -0
  141. package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +117 -0
  142. package/src/features/analytics/data/__tests__/AnalyticsService.spec.ts +56 -0
  143. package/src/features/analytics/data/__tests__/models.spec.ts +300 -0
  144. package/src/features/analytics/data/analytics-breakdown.interface.ts +9 -0
  145. package/src/features/analytics/data/analytics-breakdown.ts +65 -0
  146. package/src/features/analytics/data/analytics-event.interface.ts +7 -0
  147. package/src/features/analytics/data/analytics-event.ts +63 -0
  148. package/src/features/analytics/data/analytics-page-view.interface.ts +9 -0
  149. package/src/features/analytics/data/analytics-page-view.ts +57 -0
  150. package/src/features/analytics/data/analytics-session.interface.ts +23 -0
  151. package/src/features/analytics/data/analytics-session.ts +144 -0
  152. package/src/features/analytics/data/analytics-summary.interface.ts +11 -0
  153. package/src/features/analytics/data/analytics-summary.ts +79 -0
  154. package/src/features/analytics/data/analytics-timeline.interface.ts +10 -0
  155. package/src/features/analytics/data/analytics-timeline.ts +67 -0
  156. package/src/features/analytics/data/analytics.types.ts +97 -0
  157. package/src/features/analytics/data/index.ts +15 -0
  158. package/src/features/analytics/i18n-keys.ts +76 -0
  159. package/src/features/analytics/index.ts +31 -0
  160. package/src/features/analytics/lib/__tests__/utm.spec.ts +20 -0
  161. package/src/features/analytics/lib/__tests__/visitorCookie.spec.ts +63 -0
  162. package/src/features/analytics/lib/utm.ts +29 -0
  163. package/src/features/analytics/lib/visitorCookie.ts +32 -0
  164. package/src/features/analytics/server-entry.ts +8 -0
  165. package/src/features/waitlist/components/lists/WaitlistList.tsx +68 -121
  166. package/src/features/waitlist/data/WaitlistService.ts +2 -2
  167. package/src/features/waitlist/data/index.ts +1 -0
  168. package/src/features/waitlist/data/waitlist.fields.ts +8 -0
  169. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +69 -30
  170. package/dist/JsonApiRequest-G625QAH5.js +0 -26
  171. package/dist/chunk-2XCUAHQT.js.map +0 -1
  172. package/dist/chunk-IBVKPLHT.mjs.map +0 -1
  173. package/dist/chunk-U7QVISQS.js.map +0 -1
  174. /package/dist/{BlockNoteEditor-65HR6ULV.mjs.map → BlockNoteEditor-46O3JC4Z.mjs.map} +0 -0
  175. /package/dist/{BlockNoteViewer-KDKGUIG5.mjs.map → BlockNoteViewer-KRTAFTUX.mjs.map} +0 -0
  176. /package/dist/{JsonApiRequest-RRMNV7YE.mjs.map → JsonApiRequest-UHOWNI3O.mjs.map} +0 -0
  177. /package/dist/{chunk-T76WLDE5.mjs.map → chunk-37DMFVLN.mjs.map} +0 -0
  178. /package/dist/{chunk-ZJI5ZZW5.mjs.map → chunk-Q6J2ICKO.mjs.map} +0 -0
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
5
+ import { Modules, NextRef } from "../../../core";
6
+ import { SharedProvider } from "../../../contexts";
7
+ import { usePageUrlGenerator } from "../../../hooks";
8
+ import { BreadcrumbItemData } from "../../../interfaces";
9
+ import { AnalyticsAdminFilterBar } from "../components/AnalyticsAdminFilterBar";
10
+ import type { AnalyticsBreakdownInterface } from "../data/analytics-breakdown.interface";
11
+ import type { AnalyticsSessionInterface } from "../data/analytics-session.interface";
12
+ import type { AnalyticsSummaryInterface } from "../data/analytics-summary.interface";
13
+ import type { AnalyticsTimelineInterface } from "../data/analytics-timeline.interface";
14
+ import {
15
+ ANALYTICS_DIMENSIONS,
16
+ type AnalyticsDimension,
17
+ type AnalyticsGranularity,
18
+ type AnalyticsSection,
19
+ } from "../data/analytics.types";
20
+ import { AnalyticsAdminService } from "../data/AnalyticsAdminService";
21
+
22
+ /**
23
+ * Default route the breadcrumb links back to. A host app that mounts the page
24
+ * elsewhere overrides it with the `pageUrl` prop — the constant stays here
25
+ * because the breadcrumb is built by this provider, not by the app.
26
+ */
27
+ const ANALYTICS_ADMIN_PAGE_URL = "/administration/analytics";
28
+
29
+ /** Default base route of the journey page each session row links to. */
30
+ const ANALYTICS_SESSION_PAGE_URL = "/administration/analytics/sessions";
31
+
32
+ /** Rows kept per breakdown tab before the repository folds the tail into "other". */
33
+ const BREAKDOWN_LIMIT = 20;
34
+
35
+ /** The window the page opens on, in days. */
36
+ const DEFAULT_RANGE_DAYS = 30;
37
+
38
+ const EMPTY_BREAKDOWNS: Record<AnalyticsDimension, AnalyticsBreakdownInterface[]> = {
39
+ source: [],
40
+ medium: [],
41
+ campaign: [],
42
+ referrer: [],
43
+ route: [],
44
+ landing: [],
45
+ };
46
+
47
+ export type AnalyticsAdminFilterState = {
48
+ /** ISO 8601 instant. */
49
+ from: string;
50
+ /** ISO 8601 instant. */
51
+ to: string;
52
+ section: AnalyticsSection;
53
+ granularity: AnalyticsGranularity;
54
+ };
55
+
56
+ export interface AnalyticsAdminContextType {
57
+ /** The six summary rows: {public, app, total} × {current, previous}. */
58
+ summary: AnalyticsSummaryInterface[];
59
+ timeline: AnalyticsTimelineInterface[];
60
+ breakdowns: Record<AnalyticsDimension, AnalyticsBreakdownInterface[]>;
61
+ sessions: AnalyticsSessionInterface[];
62
+ filters: AnalyticsAdminFilterState;
63
+ /** Merges a partial patch into the current filters; every key is optional. */
64
+ setFilters: (next: Partial<AnalyticsAdminFilterState>) => void;
65
+ /** Appends the next page of sessions; a no-op when there is none. */
66
+ loadMoreSessions: () => Promise<void>;
67
+ hasMoreSessions: boolean;
68
+ /** Base route of the journey page, as passed to the provider. */
69
+ sessionPageUrl: string;
70
+ isLoading: boolean;
71
+ error: string | null;
72
+ }
73
+
74
+ const AnalyticsAdminContext = createContext<AnalyticsAdminContextType | undefined>(undefined);
75
+
76
+ /** The last 30 days up to now, which is the window the page opens on. */
77
+ function defaultRange(): { from: string; to: string } {
78
+ const now = new Date();
79
+ const start = new Date(now.getTime() - DEFAULT_RANGE_DAYS * 24 * 60 * 60 * 1000);
80
+ return { from: start.toISOString(), to: now.toISOString() };
81
+ }
82
+
83
+ type AnalyticsAdminProviderProps = {
84
+ children: ReactNode;
85
+ /** ISO 8601 instant. Defaults to 30 days ago. */
86
+ initialFrom?: string;
87
+ /** ISO 8601 instant. Defaults to now. */
88
+ initialTo?: string;
89
+ /** Route the breadcrumb links back to. */
90
+ pageUrl?: string;
91
+ /** Base route of the journey page each session row links to. */
92
+ sessionPageUrl?: string;
93
+ };
94
+
95
+ /**
96
+ * Owns every filter the administrative analytics page reads, fetches the panels
97
+ * behind it, and publishes the filter bar into the page title bar.
98
+ *
99
+ * The filter bar is rendered into `title.functions` here — NOT in the container —
100
+ * because `RoundPageContainer`'s title bar reads `title.functions` from
101
+ * `SharedContext`, and a descendant cannot inject nodes into an ancestor's
102
+ * provider value. That is why the filter state lives at this level.
103
+ */
104
+ export const AnalyticsAdminProvider = ({
105
+ children,
106
+ initialFrom,
107
+ initialTo,
108
+ pageUrl = ANALYTICS_ADMIN_PAGE_URL,
109
+ sessionPageUrl = ANALYTICS_SESSION_PAGE_URL,
110
+ }: AnalyticsAdminProviderProps) => {
111
+ const t = useTranslations();
112
+ const generateUrl = usePageUrlGenerator();
113
+
114
+ const [filters, setFilterState] = useState<AnalyticsAdminFilterState>(() => {
115
+ const range = defaultRange();
116
+ return {
117
+ from: initialFrom ?? range.from,
118
+ to: initialTo ?? range.to,
119
+ section: "all",
120
+ granularity: "day",
121
+ };
122
+ });
123
+
124
+ const [summary, setSummary] = useState<AnalyticsSummaryInterface[]>([]);
125
+ const [timeline, setTimeline] = useState<AnalyticsTimelineInterface[]>([]);
126
+ const [breakdowns, setBreakdowns] =
127
+ useState<Record<AnalyticsDimension, AnalyticsBreakdownInterface[]>>(EMPTY_BREAKDOWNS);
128
+ const [sessions, setSessions] = useState<AnalyticsSessionInterface[]>([]);
129
+ /** The cursor URL of the next sessions page, as the API returned it. */
130
+ const [sessionsNextPage, setSessionsNextPage] = useState<string | undefined>(undefined);
131
+ const [isLoading, setIsLoading] = useState(true);
132
+ const [error, setError] = useState<string | null>(null);
133
+
134
+ const { from, to, section, granularity } = filters;
135
+
136
+ useEffect(() => {
137
+ // `cancelled` is the out-of-order guard: a filter change fires a new request
138
+ // while the previous one is still in flight, and without this flag the slower
139
+ // (older) response would land last and overwrite the fresher state.
140
+ let cancelled = false;
141
+
142
+ setIsLoading(true);
143
+ setError(null);
144
+
145
+ const base = { from, to, section };
146
+ // callApi writes the response's next-page link into this ref.
147
+ const sessionsNext: NextRef = {};
148
+
149
+ Promise.all([
150
+ AnalyticsAdminService.getSummary(base),
151
+ AnalyticsAdminService.getTimeline({ ...base, granularity }),
152
+ Promise.all(
153
+ ANALYTICS_DIMENSIONS.map((dimension) =>
154
+ AnalyticsAdminService.getBreakdown({ ...base, dimension, limit: BREAKDOWN_LIMIT }),
155
+ ),
156
+ ),
157
+ AnalyticsAdminService.getSessions({ ...base, next: sessionsNext }),
158
+ ])
159
+ .then(([nextSummary, nextTimeline, nextBreakdowns, nextSessions]) => {
160
+ if (cancelled) return;
161
+ setSummary(nextSummary ?? []);
162
+ setTimeline(nextTimeline ?? []);
163
+ setBreakdowns(
164
+ Object.fromEntries(
165
+ ANALYTICS_DIMENSIONS.map((dimension, index) => [dimension, nextBreakdowns[index] ?? []]),
166
+ ) as Record<AnalyticsDimension, AnalyticsBreakdownInterface[]>,
167
+ );
168
+ setSessions(nextSessions ?? []);
169
+ setSessionsNextPage(sessionsNext.next);
170
+ })
171
+ .catch((err) => {
172
+ if (cancelled) return;
173
+ console.error("Failed to load analytics:", err);
174
+ setError(err instanceof Error ? err.message : String(err));
175
+ })
176
+ .finally(() => {
177
+ if (cancelled) return;
178
+ setIsLoading(false);
179
+ });
180
+
181
+ return () => {
182
+ cancelled = true;
183
+ };
184
+ }, [from, to, section, granularity]);
185
+
186
+ const setFilters = useCallback((next: Partial<AnalyticsAdminFilterState>) => {
187
+ setFilterState((prev) => ({ ...prev, ...next }));
188
+ }, []);
189
+
190
+ const loadMoreSessions = useCallback(async () => {
191
+ if (!sessionsNextPage) return;
192
+
193
+ const sessionsNext: NextRef = {};
194
+ try {
195
+ const nextSessions = await AnalyticsAdminService.next<AnalyticsSessionInterface[]>({
196
+ type: Modules.AnalyticsSession,
197
+ endpoint: sessionsNextPage,
198
+ next: sessionsNext,
199
+ });
200
+ setSessions((prev) => [...prev, ...(nextSessions ?? [])]);
201
+ setSessionsNextPage(sessionsNext.next);
202
+ } catch (err) {
203
+ console.error("Failed to load more analytics sessions:", err);
204
+ setError(err instanceof Error ? err.message : String(err));
205
+ }
206
+ }, [sessionsNextPage]);
207
+
208
+ const breadcrumb = (): BreadcrumbItemData[] => [
209
+ {
210
+ name: t("analytics.admin.title"),
211
+ href: generateUrl({ page: pageUrl }),
212
+ },
213
+ ];
214
+
215
+ const title = () => ({
216
+ type: t("analytics.admin.title"),
217
+ functions: (
218
+ <AnalyticsAdminFilterBar
219
+ key="analyticsAdminFilterBar"
220
+ from={from}
221
+ to={to}
222
+ section={section}
223
+ granularity={granularity}
224
+ onChange={setFilters}
225
+ />
226
+ ),
227
+ });
228
+
229
+ const hasMoreSessions = Boolean(sessionsNextPage);
230
+
231
+ const contextValue = useMemo<AnalyticsAdminContextType>(
232
+ () => ({
233
+ summary,
234
+ timeline,
235
+ breakdowns,
236
+ sessions,
237
+ filters,
238
+ setFilters,
239
+ loadMoreSessions,
240
+ hasMoreSessions,
241
+ sessionPageUrl,
242
+ isLoading,
243
+ error,
244
+ }),
245
+ [
246
+ summary,
247
+ timeline,
248
+ breakdowns,
249
+ sessions,
250
+ filters,
251
+ setFilters,
252
+ loadMoreSessions,
253
+ hasMoreSessions,
254
+ sessionPageUrl,
255
+ isLoading,
256
+ error,
257
+ ],
258
+ );
259
+
260
+ return (
261
+ <SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
262
+ <AnalyticsAdminContext.Provider value={contextValue}>{children}</AnalyticsAdminContext.Provider>
263
+ </SharedProvider>
264
+ );
265
+ };
266
+
267
+ export const useAnalyticsAdmin = (): AnalyticsAdminContextType => {
268
+ const ctx = useContext(AnalyticsAdminContext);
269
+ if (!ctx) {
270
+ throw new Error("useAnalyticsAdmin() called outside <AnalyticsAdminProvider>.");
271
+ }
272
+ return ctx;
273
+ };
@@ -0,0 +1,140 @@
1
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { AnalyticsAdminService } from "../../data/AnalyticsAdminService";
4
+ import { AnalyticsAdminProvider, useAnalyticsAdmin } from "../AnalyticsAdminContext";
5
+
6
+ vi.mock("../../data/AnalyticsAdminService", () => ({
7
+ AnalyticsAdminService: {
8
+ getSummary: vi.fn(async () => []),
9
+ getTimeline: vi.fn(async () => []),
10
+ getBreakdown: vi.fn(async () => []),
11
+ getSessions: vi.fn(async () => []),
12
+ next: vi.fn(async () => []),
13
+ },
14
+ }));
15
+
16
+ // The registry throws for modules nobody registered; the provider only needs
17
+ // the sessions module as the `type` of the load-more request.
18
+ vi.mock("../../../../core", async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import("../../../../core")>();
20
+ return {
21
+ ...actual,
22
+ Modules: { AnalyticsSession: { name: "analytics/administration/sessions" } },
23
+ };
24
+ });
25
+
26
+ function Probe() {
27
+ const ctx = useAnalyticsAdmin();
28
+ return (
29
+ <div>
30
+ <span data-testid="sessions">{ctx.sessions.map((s) => s.id).join(",")}</span>
31
+ <span data-testid="has-more">{String(ctx.hasMoreSessions)}</span>
32
+ <span data-testid="section">{ctx.filters.section}</span>
33
+ <button type="button" onClick={() => ctx.setFilters({ section: "app" })}>
34
+ app
35
+ </button>
36
+ <button type="button" onClick={() => void ctx.loadMoreSessions()}>
37
+ more
38
+ </button>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ describe("AnalyticsAdminContext", () => {
44
+ // The mocked service is module-level, so its call counts survive across tests.
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ });
48
+
49
+ it("throws when used outside its provider", () => {
50
+ expect(() => render(<Probe />)).toThrow();
51
+ });
52
+
53
+ it("requests every panel on mount: summary, timeline, one breakdown per dimension and the sessions", async () => {
54
+ render(
55
+ <AnalyticsAdminProvider>
56
+ <Probe />
57
+ </AnalyticsAdminProvider>,
58
+ );
59
+
60
+ await waitFor(() => expect(AnalyticsAdminService.getSessions).toHaveBeenCalledTimes(1));
61
+
62
+ expect(AnalyticsAdminService.getSummary).toHaveBeenCalledTimes(1);
63
+ expect(AnalyticsAdminService.getTimeline).toHaveBeenCalledWith(
64
+ expect.objectContaining({ section: "all", granularity: "day" }),
65
+ );
66
+
67
+ const breakdowns = vi
68
+ .mocked(AnalyticsAdminService.getBreakdown)
69
+ .mock.calls.map(([args]) => [args.dimension, args.limit]);
70
+ expect(breakdowns).toEqual(
71
+ expect.arrayContaining([
72
+ ["source", 20],
73
+ ["medium", 20],
74
+ ["campaign", 20],
75
+ ["referrer", 20],
76
+ ["route", 20],
77
+ ["landing", 20],
78
+ ]),
79
+ );
80
+ expect(breakdowns).toHaveLength(6);
81
+ });
82
+
83
+ it("defaults to the last 30 days", async () => {
84
+ render(
85
+ <AnalyticsAdminProvider>
86
+ <Probe />
87
+ </AnalyticsAdminProvider>,
88
+ );
89
+
90
+ await waitFor(() => expect(AnalyticsAdminService.getSummary).toHaveBeenCalled());
91
+
92
+ const { from, to } = vi.mocked(AnalyticsAdminService.getSummary).mock.calls[0][0];
93
+ const days = (Date.parse(to) - Date.parse(from)) / (24 * 60 * 60 * 1000);
94
+ expect(Math.round(days)).toBe(30);
95
+ });
96
+
97
+ it("refetches when a filter changes", async () => {
98
+ render(
99
+ <AnalyticsAdminProvider>
100
+ <Probe />
101
+ </AnalyticsAdminProvider>,
102
+ );
103
+ await waitFor(() => expect(AnalyticsAdminService.getSummary).toHaveBeenCalledTimes(1));
104
+
105
+ fireEvent.click(screen.getByText("app"));
106
+
107
+ await waitFor(() => expect(AnalyticsAdminService.getSummary).toHaveBeenCalledTimes(2));
108
+ expect(AnalyticsAdminService.getSummary).toHaveBeenLastCalledWith(expect.objectContaining({ section: "app" }));
109
+ expect(screen.getByTestId("section")).toHaveTextContent("app");
110
+ });
111
+
112
+ it("appends the next page of sessions on loadMoreSessions", async () => {
113
+ vi.mocked(AnalyticsAdminService.getSessions).mockImplementationOnce(async (params: any) => {
114
+ params.next.next = "https://api.example.com/analytics/administration/sessions?page[cursor]=2";
115
+ return [{ id: "s1" }] as any;
116
+ });
117
+ vi.mocked(AnalyticsAdminService.next).mockResolvedValueOnce([{ id: "s2" }] as any);
118
+
119
+ render(
120
+ <AnalyticsAdminProvider>
121
+ <Probe />
122
+ </AnalyticsAdminProvider>,
123
+ );
124
+
125
+ await waitFor(() => expect(screen.getByTestId("sessions")).toHaveTextContent("s1"));
126
+ expect(screen.getByTestId("has-more")).toHaveTextContent("true");
127
+
128
+ await act(async () => {
129
+ fireEvent.click(screen.getByText("more"));
130
+ });
131
+
132
+ await waitFor(() => expect(screen.getByTestId("sessions")).toHaveTextContent("s1,s2"));
133
+ expect(AnalyticsAdminService.next).toHaveBeenCalledWith(
134
+ expect.objectContaining({
135
+ endpoint: "https://api.example.com/analytics/administration/sessions?page[cursor]=2",
136
+ }),
137
+ );
138
+ expect(screen.getByTestId("has-more")).toHaveTextContent("false");
139
+ });
140
+ });
@@ -0,0 +1,91 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef } from "../../../core";
2
+ import { AnalyticsBreakdownInterface } from "./analytics-breakdown.interface";
3
+ import { AnalyticsPageViewInterface } from "./analytics-page-view.interface";
4
+ import { AnalyticsSessionInterface } from "./analytics-session.interface";
5
+ import { AnalyticsSummaryInterface } from "./analytics-summary.interface";
6
+ import { AnalyticsTimelineInterface } from "./analytics-timeline.interface";
7
+ import { AnalyticsDimension, AnalyticsFilters, AnalyticsGranularity } from "./analytics.types";
8
+
9
+ function withFilters(endpoint: EndpointCreator, filters: AnalyticsFilters): EndpointCreator {
10
+ endpoint.addAdditionalParam("from", filters.from);
11
+ endpoint.addAdditionalParam("to", filters.to);
12
+ endpoint.addAdditionalParam("section", filters.section);
13
+ return endpoint;
14
+ }
15
+
16
+ export class AnalyticsAdminService extends AbstractService {
17
+ /** Headline tiles: one row per window (current, previous). */
18
+ static async getSummary(filters: AnalyticsFilters): Promise<AnalyticsSummaryInterface[]> {
19
+ const endpoint = withFilters(new EndpointCreator({ endpoint: Modules.AnalyticsSummary }), filters);
20
+
21
+ return this.callApi<AnalyticsSummaryInterface[]>({
22
+ type: Modules.AnalyticsSummary,
23
+ method: HttpMethod.GET,
24
+ endpoint: endpoint.generate(),
25
+ });
26
+ }
27
+
28
+ static async getTimeline(
29
+ params: AnalyticsFilters & { granularity: AnalyticsGranularity },
30
+ ): Promise<AnalyticsTimelineInterface[]> {
31
+ const endpoint = withFilters(new EndpointCreator({ endpoint: Modules.AnalyticsTimeline }), params);
32
+ endpoint.addAdditionalParam("granularity", params.granularity);
33
+
34
+ return this.callApi<AnalyticsTimelineInterface[]>({
35
+ type: Modules.AnalyticsTimeline,
36
+ method: HttpMethod.GET,
37
+ endpoint: endpoint.generate(),
38
+ });
39
+ }
40
+
41
+ static async getBreakdown(
42
+ params: AnalyticsFilters & { dimension: AnalyticsDimension; limit?: number },
43
+ ): Promise<AnalyticsBreakdownInterface[]> {
44
+ const endpoint = withFilters(new EndpointCreator({ endpoint: Modules.AnalyticsBreakdown }), params);
45
+ endpoint.addAdditionalParam("dimension", params.dimension);
46
+ if (params.limit !== undefined) endpoint.addAdditionalParam("limit", String(params.limit));
47
+
48
+ return this.callApi<AnalyticsBreakdownInterface[]>({
49
+ type: Modules.AnalyticsBreakdown,
50
+ method: HttpMethod.GET,
51
+ endpoint: endpoint.generate(),
52
+ });
53
+ }
54
+
55
+ /** Newest sessions first, cursor-paginated through `next`. */
56
+ static async getSessions(
57
+ params: AnalyticsFilters & { visitorId?: string; userId?: string; next?: NextRef },
58
+ ): Promise<AnalyticsSessionInterface[]> {
59
+ const endpoint = withFilters(new EndpointCreator({ endpoint: Modules.AnalyticsSession }), params);
60
+ if (params.visitorId) endpoint.addAdditionalParam("visitorId", params.visitorId);
61
+ if (params.userId) endpoint.addAdditionalParam("userId", params.userId);
62
+
63
+ return this.callApi<AnalyticsSessionInterface[]>({
64
+ type: Modules.AnalyticsSession,
65
+ method: HttpMethod.GET,
66
+ endpoint: endpoint.generate(),
67
+ next: params.next,
68
+ });
69
+ }
70
+
71
+ static async getSession(params: { id: string }): Promise<AnalyticsSessionInterface> {
72
+ return this.callApi<AnalyticsSessionInterface>({
73
+ type: Modules.AnalyticsSession,
74
+ method: HttpMethod.GET,
75
+ endpoint: new EndpointCreator({ endpoint: Modules.AnalyticsSession, id: params.id }).generate(),
76
+ });
77
+ }
78
+
79
+ /** The journey: every page view of one session, oldest first. */
80
+ static async getSessionPageViews(params: { sessionId: string }): Promise<AnalyticsPageViewInterface[]> {
81
+ return this.callApi<AnalyticsPageViewInterface[]>({
82
+ type: Modules.AnalyticsPageView,
83
+ method: HttpMethod.GET,
84
+ endpoint: new EndpointCreator({
85
+ endpoint: Modules.AnalyticsSession,
86
+ id: params.sessionId,
87
+ childEndpoint: Modules.AnalyticsPageView,
88
+ }).generate(),
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,23 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
2
+ import { AnalyticsEventInput } from "./analytics.types";
3
+
4
+ export class AnalyticsService extends AbstractService {
5
+ /**
6
+ * Posts one page view. Fire-and-forget: a lost page view must never surface
7
+ * an error to the visitor, so the global handler is suppressed and any
8
+ * rejection is logged at debug level and swallowed here.
9
+ */
10
+ static async track(input: AnalyticsEventInput): Promise<void> {
11
+ try {
12
+ await this.callApi({
13
+ type: Modules.AnalyticsEvent,
14
+ method: HttpMethod.POST,
15
+ endpoint: new EndpointCreator({ endpoint: Modules.AnalyticsEvent }).generate(),
16
+ input,
17
+ suppressGlobalError: true,
18
+ });
19
+ } catch (error) {
20
+ console.debug("Analytics event not sent", error);
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,117 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AbstractService } from "../../../../core/abstracts/AbstractService";
3
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
4
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
5
+ import { ModuleFactory, ModuleWithPermissions } from "../../../../permissions/types";
6
+ import { analyticsModules } from "../../analytics.modules";
7
+ import { AnalyticsAdminService } from "../AnalyticsAdminService";
8
+
9
+ const moduleFactory: ModuleFactory = (params) =>
10
+ ({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
11
+
12
+ // ModuleRegistry is shared across test files in a worker, hence the
13
+ // register-if-absent guard (same idiom as HandbookThreadService.spec.ts).
14
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
15
+ try {
16
+ ModuleRegistry.get(key as any);
17
+ } catch {
18
+ ModuleRegistry.register(key, module);
19
+ }
20
+ };
21
+
22
+ beforeAll(() => {
23
+ for (const [key, module] of Object.entries(analyticsModules(moduleFactory))) registerIfAbsent(key, module);
24
+ });
25
+
26
+ type CallApiParams = { type: ApiRequestDataTypeInterface; method: string; endpoint: string; next?: unknown };
27
+
28
+ // `callApi` is a protected static on AbstractService; spying on the base class
29
+ // intercepts every call without touching the HTTP layer.
30
+ const callApi = () => vi.spyOn(AbstractService as any, "callApi").mockResolvedValue([]);
31
+ const firstCall = (spy: ReturnType<typeof callApi>): CallApiParams => spy.mock.calls[0][0] as CallApiParams;
32
+
33
+ const filters = { from: "2026-09-01T00:00:00.000Z", to: "2026-10-01T00:00:00.000Z", section: "public" as const };
34
+
35
+ describe("AnalyticsAdminService", () => {
36
+ beforeEach(() => vi.restoreAllMocks());
37
+
38
+ it("getSummary sends from, to and section to the summary endpoint", async () => {
39
+ const spy = callApi();
40
+
41
+ await AnalyticsAdminService.getSummary(filters);
42
+
43
+ const call = firstCall(spy);
44
+ expect(call.type).toBe(ModuleRegistry.get("AnalyticsSummary" as any));
45
+ expect(call.method).toBe("GET");
46
+ expect(call.endpoint).toBe(
47
+ "analytics/administration/summary?from=2026-09-01T00:00:00.000Z&to=2026-10-01T00:00:00.000Z&section=public",
48
+ );
49
+ });
50
+
51
+ it("getTimeline builds the endpoint from Modules.AnalyticsTimeline with from, to, section and granularity", async () => {
52
+ const spy = callApi();
53
+
54
+ await AnalyticsAdminService.getTimeline({ ...filters, granularity: "week" });
55
+
56
+ const call = firstCall(spy);
57
+ expect(call.type).toBe(ModuleRegistry.get("AnalyticsTimeline" as any));
58
+ expect(call.endpoint).toBe(
59
+ "analytics/administration/timeline?from=2026-09-01T00:00:00.000Z&to=2026-10-01T00:00:00.000Z&section=public&granularity=week",
60
+ );
61
+ });
62
+
63
+ it("getBreakdown adds dimension and limit", async () => {
64
+ const spy = callApi();
65
+
66
+ await AnalyticsAdminService.getBreakdown({ ...filters, dimension: "source", limit: 50 });
67
+
68
+ const call = firstCall(spy);
69
+ expect(call.type).toBe(ModuleRegistry.get("AnalyticsBreakdown" as any));
70
+ expect(call.endpoint).toBe(
71
+ "analytics/administration/breakdown?from=2026-09-01T00:00:00.000Z&to=2026-10-01T00:00:00.000Z&section=public&dimension=source&limit=50",
72
+ );
73
+ });
74
+
75
+ it("getBreakdown leaves limit out when it is not given", async () => {
76
+ const spy = callApi();
77
+
78
+ await AnalyticsAdminService.getBreakdown({ ...filters, dimension: "route" });
79
+
80
+ expect(firstCall(spy).endpoint).not.toContain("limit=");
81
+ });
82
+
83
+ it("getSessions forwards next and the visitor and user filters", async () => {
84
+ const spy = callApi();
85
+ const next = { next: "analytics/administration/sessions?page[cursor]=25" };
86
+
87
+ await AnalyticsAdminService.getSessions({ ...filters, visitorId: "v1", userId: "u1", next });
88
+
89
+ const call = firstCall(spy);
90
+ expect(call.type).toBe(ModuleRegistry.get("AnalyticsSession" as any));
91
+ expect(call.next).toBe(next);
92
+ expect(call.endpoint).toBe(
93
+ "analytics/administration/sessions?from=2026-09-01T00:00:00.000Z&to=2026-10-01T00:00:00.000Z&section=public&visitorId=v1&userId=u1",
94
+ );
95
+ });
96
+
97
+ it("getSession reads one session by id", async () => {
98
+ const spy = vi.spyOn(AbstractService as any, "callApi").mockResolvedValue({});
99
+
100
+ await AnalyticsAdminService.getSession({ id: "s1" });
101
+
102
+ const call = firstCall(spy);
103
+ expect(call.type).toBe(ModuleRegistry.get("AnalyticsSession" as any));
104
+ expect(call.endpoint).toBe("analytics/administration/sessions/s1");
105
+ });
106
+
107
+ it("getSessionPageViews uses the session endpoint with the pageviews child segment", async () => {
108
+ const spy = callApi();
109
+
110
+ await AnalyticsAdminService.getSessionPageViews({ sessionId: "s1" });
111
+
112
+ const call = firstCall(spy);
113
+ expect(call.type).toBe(ModuleRegistry.get("AnalyticsPageView" as any));
114
+ expect(call.method).toBe("GET");
115
+ expect(call.endpoint).toBe("analytics/administration/sessions/s1/pageviews");
116
+ });
117
+ });