@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,121 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { beforeAll, describe, expect, it, vi } from "vitest";
3
+ import { configureI18n } from "../../../../i18n";
4
+ import { AnalyticsSessionsTable } from "../AnalyticsSessionsTable";
5
+
6
+ beforeAll(() => {
7
+ // The package Link resolves its inner component at runtime and throws if i18n
8
+ // was never configured (src/i18n/config.ts).
9
+ configureI18n({
10
+ useRouter: () => ({
11
+ push: vi.fn(),
12
+ replace: vi.fn(),
13
+ back: vi.fn(),
14
+ forward: vi.fn(),
15
+ refresh: vi.fn(),
16
+ prefetch: vi.fn(),
17
+ }),
18
+ useTranslations: () => (key: string) => key,
19
+ usePathname: () => "/",
20
+ Link: ({
21
+ href,
22
+ children,
23
+ prefetch: _prefetch,
24
+ ...rest
25
+ }: {
26
+ href: string;
27
+ children: React.ReactNode;
28
+ [key: string]: any;
29
+ }) => (
30
+ <a href={href} {...rest}>
31
+ {children}
32
+ </a>
33
+ ),
34
+ });
35
+ });
36
+
37
+ const session = (id: string, extra: Record<string, unknown> = {}) =>
38
+ ({
39
+ id,
40
+ visitorId: `visitor-0000-${id}-abc123`,
41
+ consented: false,
42
+ startedAt: new Date("2026-10-01T10:00:00.000Z"),
43
+ lastSeenAt: new Date("2026-10-01T10:05:00.000Z"),
44
+ pageViews: 3,
45
+ section: "public",
46
+ landingRoute: "/pricing",
47
+ utmSource: "linkedin",
48
+ utmMedium: "social",
49
+ deviceType: "desktop",
50
+ ...extra,
51
+ }) as any;
52
+
53
+ const SESSION_PAGE_URL = "/administration/analytics/sessions";
54
+
55
+ describe("AnalyticsSessionsTable", () => {
56
+ it("renders the linked user's name and email", () => {
57
+ render(
58
+ <AnalyticsSessionsTable
59
+ rows={[session("s1", { user: { id: "u1", name: "Ada Lovelace", email: "ada@example.com" } })]}
60
+ sessionPageUrl={SESSION_PAGE_URL}
61
+ onLoadMore={vi.fn()}
62
+ hasMore={false}
63
+ />,
64
+ );
65
+
66
+ expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();
67
+ expect(screen.getByText("ada@example.com")).toBeInTheDocument();
68
+ expect(screen.queryByText("analytics.admin.anonymous")).not.toBeInTheDocument();
69
+ });
70
+
71
+ it("renders an anonymous visitor with the last six characters of the visitor id", () => {
72
+ render(
73
+ <AnalyticsSessionsTable
74
+ rows={[session("s2")]}
75
+ sessionPageUrl={SESSION_PAGE_URL}
76
+ onLoadMore={vi.fn()}
77
+ hasMore={false}
78
+ />,
79
+ );
80
+
81
+ expect(screen.getByText("analytics.admin.anonymous")).toBeInTheDocument();
82
+ expect(screen.getByText("abc123")).toBeInTheDocument();
83
+ });
84
+
85
+ it("links each row to its journey page", () => {
86
+ render(
87
+ <AnalyticsSessionsTable
88
+ rows={[session("s3")]}
89
+ sessionPageUrl={SESSION_PAGE_URL}
90
+ onLoadMore={vi.fn()}
91
+ hasMore={false}
92
+ />,
93
+ );
94
+
95
+ expect(screen.getByTestId("analytics-session-link-s3")).toHaveAttribute("href", `${SESSION_PAGE_URL}/s3`);
96
+ });
97
+
98
+ it("shows the load-more button only when more sessions exist", () => {
99
+ const onLoadMore = vi.fn();
100
+ const { rerender } = render(
101
+ <AnalyticsSessionsTable
102
+ rows={[session("s4")]}
103
+ sessionPageUrl={SESSION_PAGE_URL}
104
+ onLoadMore={onLoadMore}
105
+ hasMore={false}
106
+ />,
107
+ );
108
+ expect(screen.queryByText("analytics.admin.load_more")).not.toBeInTheDocument();
109
+
110
+ rerender(
111
+ <AnalyticsSessionsTable
112
+ rows={[session("s4")]}
113
+ sessionPageUrl={SESSION_PAGE_URL}
114
+ onLoadMore={onLoadMore}
115
+ hasMore
116
+ />,
117
+ );
118
+ fireEvent.click(screen.getByText("analytics.admin.load_more"));
119
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
120
+ });
121
+ });
@@ -0,0 +1,35 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { AnalyticsTimelineChart } from "../AnalyticsTimelineChart";
4
+
5
+ const row = (bucket: string, section: string, visitors: number, pageViews: number) =>
6
+ ({
7
+ id: `${bucket}|${section}`,
8
+ bucket: new Date(bucket),
9
+ section,
10
+ visitors,
11
+ sessions: visitors,
12
+ pageViews,
13
+ }) as any;
14
+
15
+ describe("AnalyticsTimelineChart", () => {
16
+ it("pivots flat rows into one entry per bucket with visitors and pageViews", () => {
17
+ render(
18
+ <AnalyticsTimelineChart
19
+ rows={[row("2026-10-01", "public", 3, 10), row("2026-10-01", "app", 2, 5), row("2026-10-02", "public", 4, 8)]}
20
+ />,
21
+ );
22
+
23
+ const data = JSON.parse(screen.getByTestId("analytics-timeline-data").textContent!);
24
+ expect(data).toHaveLength(2);
25
+ expect(data[0]).toMatchObject({ bucket: "2026-10-01", visitors: 5, pageViews: 15 });
26
+ expect(data[1]).toMatchObject({ bucket: "2026-10-02", visitors: 4, pageViews: 8 });
27
+ });
28
+
29
+ it("renders the empty state without a data node when there are no rows", () => {
30
+ render(<AnalyticsTimelineChart rows={[]} />);
31
+
32
+ expect(screen.queryByTestId("analytics-timeline-data")).not.toBeInTheDocument();
33
+ expect(screen.getByText("analytics.admin.no_data")).toBeInTheDocument();
34
+ });
35
+ });
@@ -0,0 +1,248 @@
1
+ import { render } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { AnalyticsTracker } from "../AnalyticsTracker";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ pathname: "/",
7
+ track: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("next/navigation", () => ({
11
+ usePathname: () => mocks.pathname,
12
+ }));
13
+
14
+ vi.mock("../../data/AnalyticsService", () => ({
15
+ AnalyticsService: { track: mocks.track },
16
+ }));
17
+
18
+ // The cookie helpers come from Task 7. These stand-ins keep the same contract
19
+ // against jsdom's document.cookie so the assertions below read the real cookie jar.
20
+ vi.mock("../../lib/visitorCookie", () => {
21
+ const NAME = "analytics_visitor";
22
+ const readVisitorCookie = vi.fn(() => {
23
+ const entry = document.cookie.split("; ").find((part) => part.startsWith(`${NAME}=`));
24
+ return entry ? entry.slice(NAME.length + 1) : undefined;
25
+ });
26
+ return {
27
+ ANALYTICS_VISITOR_COOKIE: NAME,
28
+ readVisitorCookie,
29
+ ensureVisitorCookie: vi.fn(() => {
30
+ const existing = readVisitorCookie();
31
+ if (existing) return existing;
32
+ const id = crypto.randomUUID();
33
+ document.cookie = `${NAME}=${id}; path=/; SameSite=Lax`;
34
+ return id;
35
+ }),
36
+ clearVisitorCookie: vi.fn(() => {
37
+ document.cookie = `${NAME}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
38
+ }),
39
+ };
40
+ });
41
+
42
+ vi.mock("../../lib/utm", () => ({
43
+ readUtm: vi.fn((search: string) => {
44
+ const params = new URLSearchParams(search);
45
+ const source = params.get("utm_source");
46
+ return source ? { utmSource: source } : {};
47
+ }),
48
+ }));
49
+
50
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
51
+ const publicSection = () => "public" as const;
52
+
53
+ const setReferrer = (value: string) => {
54
+ Object.defineProperty(document, "referrer", { value, configurable: true });
55
+ };
56
+
57
+ const setWebdriver = (value: boolean) => {
58
+ Object.defineProperty(window.navigator, "webdriver", { value, configurable: true });
59
+ };
60
+
61
+ const clearCookie = () => {
62
+ document.cookie = "analytics_visitor=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
63
+ };
64
+
65
+ const eventAt = (index: number) => mocks.track.mock.calls[index][0];
66
+
67
+ describe("AnalyticsTracker", () => {
68
+ beforeEach(() => {
69
+ mocks.pathname = "/";
70
+ mocks.track.mockReset();
71
+ mocks.track.mockResolvedValue(undefined);
72
+ setReferrer("");
73
+ setWebdriver(false);
74
+ clearCookie();
75
+ window.history.replaceState(null, "", "/");
76
+ });
77
+
78
+ afterEach(() => {
79
+ clearCookie();
80
+ });
81
+
82
+ it("sends one event per pathname and none on re-render with the same pathname", () => {
83
+ mocks.pathname = "/pricing";
84
+ const { rerender } = render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
85
+ rerender(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
86
+ rerender(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
87
+ expect(mocks.track).toHaveBeenCalledTimes(1);
88
+
89
+ mocks.pathname = "/about";
90
+ rerender(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
91
+ expect(mocks.track).toHaveBeenCalledTimes(2);
92
+ expect(eventAt(1)).toEqual(expect.objectContaining({ path: "/about", section: "public" }));
93
+ });
94
+
95
+ it("first event carries referrer and UTM, later events do not", () => {
96
+ setReferrer("https://www.linkedin.com/");
97
+ window.history.replaceState(null, "", "/?utm_source=linkedin");
98
+ mocks.pathname = "/";
99
+ const { rerender } = render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
100
+
101
+ mocks.pathname = "/pricing";
102
+ rerender(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
103
+
104
+ expect(mocks.track).toHaveBeenCalledTimes(2);
105
+ expect(eventAt(0)).toEqual(expect.objectContaining({ referrer: expect.any(String), utmSource: "linkedin" }));
106
+ expect(eventAt(1).utmSource).toBeUndefined();
107
+ expect(eventAt(1).referrer).toBeUndefined();
108
+ });
109
+
110
+ it("re-sends the first event's attribution when consent is granted", () => {
111
+ setReferrer("https://www.linkedin.com/");
112
+ window.history.replaceState(null, "", "/?utm_source=linkedin");
113
+ mocks.pathname = "/";
114
+ const { rerender } = render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
115
+
116
+ rerender(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
117
+
118
+ mocks.pathname = "/pricing";
119
+ rerender(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
120
+
121
+ expect(mocks.track).toHaveBeenCalledTimes(3);
122
+ expect(eventAt(1)).toEqual(
123
+ expect.objectContaining({
124
+ utmSource: "linkedin",
125
+ referrer: eventAt(0).referrer,
126
+ visitorId: expect.stringMatching(UUID),
127
+ }),
128
+ );
129
+ expect(eventAt(1).referrer).toBe("https://www.linkedin.com/");
130
+ expect(eventAt(2).utmSource).toBeUndefined();
131
+ expect(eventAt(2).referrer).toBeUndefined();
132
+ });
133
+
134
+ it("drops a same-host referrer", () => {
135
+ setReferrer(`${window.location.origin}/pricing`);
136
+ mocks.pathname = "/about";
137
+ render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
138
+
139
+ expect(eventAt(0).referrer).toBeUndefined();
140
+ });
141
+
142
+ it("keeps an external referrer", () => {
143
+ setReferrer("https://news.ycombinator.com/item?id=1");
144
+ mocks.pathname = "/about";
145
+ render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
146
+
147
+ expect(eventAt(0).referrer).toBe("https://news.ycombinator.com/item?id=1");
148
+ });
149
+
150
+ it("masks segments under sensitive prefixes", () => {
151
+ const prefixes = ["/reset", "/activation", "/invitation"];
152
+ mocks.pathname = "/reset/abc123";
153
+ const { rerender } = render(
154
+ <AnalyticsTracker hasConsent={false} resolveSection={publicSection} sensitivePrefixes={prefixes} />,
155
+ );
156
+
157
+ mocks.pathname = "/invitation/abc123/def456";
158
+ rerender(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} sensitivePrefixes={prefixes} />);
159
+
160
+ mocks.pathname = "/activation";
161
+ rerender(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} sensitivePrefixes={prefixes} />);
162
+
163
+ expect(eventAt(0).path).toBe("/reset/:code");
164
+ expect(eventAt(1).path).toBe("/invitation/:code/:code");
165
+ expect(eventAt(2).path).toBe("/activation");
166
+ });
167
+
168
+ it("leaves other paths untouched", () => {
169
+ const prefixes = ["/reset"];
170
+ mocks.pathname = "/resets/abc123";
171
+ const { rerender } = render(
172
+ <AnalyticsTracker hasConsent={false} resolveSection={publicSection} sensitivePrefixes={prefixes} />,
173
+ );
174
+
175
+ mocks.pathname = "/rolls/abc123";
176
+ rerender(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} sensitivePrefixes={prefixes} />);
177
+
178
+ expect(eventAt(0).path).toBe("/resets/abc123");
179
+ expect(eventAt(1).path).toBe("/rolls/abc123");
180
+ });
181
+
182
+ it("strips query and hash from path", () => {
183
+ window.history.replaceState(null, "", "/pricing?x=1#plans");
184
+ mocks.pathname = "/pricing";
185
+ render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
186
+
187
+ expect(eventAt(0).path).toBe("/pricing");
188
+ });
189
+
190
+ it("sends the cookie id only with consent, and creates it", () => {
191
+ mocks.pathname = "/pricing";
192
+ render(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
193
+
194
+ expect(eventAt(0).visitorId).toMatch(UUID);
195
+ expect(document.cookie).toContain(`analytics_visitor=${eventAt(0).visitorId}`);
196
+ });
197
+
198
+ it("sends no id and clears the cookie without consent", () => {
199
+ document.cookie = "analytics_visitor=6f1c2d3e-0000-4000-8000-000000000001; path=/";
200
+ mocks.pathname = "/pricing";
201
+ render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
202
+
203
+ expect(eventAt(0).visitorId).toBeUndefined();
204
+ expect(document.cookie).not.toContain("analytics_visitor");
205
+ });
206
+
207
+ it("switches to cookie id after consent", () => {
208
+ mocks.pathname = "/pricing";
209
+ const { rerender } = render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />);
210
+ expect(eventAt(0).visitorId).toBeUndefined();
211
+
212
+ rerender(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
213
+
214
+ expect(mocks.track).toHaveBeenCalledTimes(2);
215
+ expect(eventAt(1)).toEqual(expect.objectContaining({ path: "/pricing", visitorId: expect.stringMatching(UUID) }));
216
+ });
217
+
218
+ it("skips automated browsers and the analytics admin pages", () => {
219
+ setWebdriver(true);
220
+ mocks.pathname = "/pricing";
221
+ const { unmount } = render(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
222
+ expect(mocks.track).not.toHaveBeenCalled();
223
+ unmount();
224
+
225
+ setWebdriver(false);
226
+ mocks.pathname = "/administration/analytics";
227
+ render(<AnalyticsTracker hasConsent={true} resolveSection={publicSection} />);
228
+ expect(mocks.track).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it("passes the app's section", () => {
232
+ const resolveSection = vi.fn(() => "app" as const);
233
+ mocks.pathname = "/rolls";
234
+ render(<AnalyticsTracker hasConsent={false} resolveSection={resolveSection} />);
235
+
236
+ expect(resolveSection).toHaveBeenCalledWith("/rolls");
237
+ expect(eventAt(0).section).toBe("app");
238
+ });
239
+
240
+ it("never throws when track rejects", async () => {
241
+ mocks.track.mockRejectedValue(new Error("network down"));
242
+ mocks.pathname = "/pricing";
243
+
244
+ expect(() => render(<AnalyticsTracker hasConsent={false} resolveSection={publicSection} />)).not.toThrow();
245
+ await Promise.resolve();
246
+ expect(mocks.track).toHaveBeenCalledTimes(1);
247
+ });
248
+ });
@@ -0,0 +1,28 @@
1
+ import { redirect } from "next/navigation";
2
+ // The container is imported through the package's own client subpath, never by
3
+ // relative path. tsup keeps this specifier external, so the built server entry
4
+ // reaches the container through `features/analytics/index`, which carries the
5
+ // "use client" banner. A relative import would inline the container's hooks and
6
+ // contexts into this server bundle, outside any client boundary.
7
+ import { AnalyticsAdminPageContainer } from "@carlonicora/nextjs-jsonapi/analytics";
8
+ import { ServerSession } from "../../../../server";
9
+
10
+ type Props = {
11
+ /** Role id the viewer must hold. The package has no role constants, so the host app passes its own. */
12
+ adminRoleId: string;
13
+ /** Route the breadcrumb links back to. */
14
+ pageUrl?: string;
15
+ /** Base route of the journey page each session row links to. */
16
+ sessionPageUrl?: string;
17
+ /** Where a viewer without the role is sent. */
18
+ deniedRedirect?: string;
19
+ };
20
+
21
+ /** Server page for the administrative analytics dashboard: role gate, then the client container. */
22
+ export async function AnalyticsAdminPage({ adminRoleId, pageUrl, sessionPageUrl, deniedRedirect = "/" }: Props) {
23
+ if (!(await ServerSession.hasRole(adminRoleId))) {
24
+ redirect(deniedRedirect);
25
+ }
26
+
27
+ return <AnalyticsAdminPageContainer pageUrl={pageUrl} sessionPageUrl={sessionPageUrl} />;
28
+ }
@@ -0,0 +1,25 @@
1
+ import { redirect } from "next/navigation";
2
+ // Imported through the package's own client subpath for the same reason as in
3
+ // AnalyticsAdminPage: the built server entry must reach the container through
4
+ // the "use client" bundle, not inline it.
5
+ import { AnalyticsJourneyPageContainer } from "@carlonicora/nextjs-jsonapi/analytics";
6
+ import { ServerSession } from "../../../../server";
7
+
8
+ type Props = {
9
+ /** Role id the viewer must hold. The package has no role constants, so the host app passes its own. */
10
+ adminRoleId: string;
11
+ sessionId: string;
12
+ /** Route of the dashboard; the journey pages live under `${pageUrl}/sessions`. */
13
+ pageUrl?: string;
14
+ /** Where a viewer without the role is sent. */
15
+ deniedRedirect?: string;
16
+ };
17
+
18
+ /** Server page for one session's journey: role gate, then the client container. */
19
+ export async function AnalyticsJourneyPage({ adminRoleId, sessionId, pageUrl, deniedRedirect = "/" }: Props) {
20
+ if (!(await ServerSession.hasRole(adminRoleId))) {
21
+ redirect(deniedRedirect);
22
+ }
23
+
24
+ return <AnalyticsJourneyPageContainer sessionId={sessionId} pageUrl={pageUrl} />;
25
+ }