@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.
- package/dist/{AssistantMessageInterface-1Kbz8RVs.d.ts → AssistantMessageInterface-CubxuBIJ.d.ts} +1 -1
- package/dist/{AssistantMessageInterface-CoxJyuFM.d.mts → AssistantMessageInterface-sHjI2zPN.d.mts} +1 -1
- package/dist/{BlockNoteEditor-35D4R7HJ.js → BlockNoteEditor-4EIKMNY3.js} +31 -21
- package/dist/BlockNoteEditor-4EIKMNY3.js.map +1 -0
- package/dist/{BlockNoteEditor-65HR6ULV.mjs → BlockNoteEditor-AXYVDDXM.mjs} +16 -6
- package/dist/{BlockNoteEditor-65HR6ULV.mjs.map → BlockNoteEditor-AXYVDDXM.mjs.map} +1 -1
- package/dist/{BlockNoteViewer-QDXDVYEM.js → BlockNoteViewer-7BFNTN4E.js} +4 -4
- package/dist/{BlockNoteViewer-QDXDVYEM.js.map → BlockNoteViewer-7BFNTN4E.js.map} +1 -1
- package/dist/{BlockNoteViewer-KDKGUIG5.mjs → BlockNoteViewer-KRTAFTUX.mjs} +3 -3
- package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-Ck1wZJ81.d.ts} +1 -1
- package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-DgYH--Ik.d.mts} +1 -1
- package/dist/JsonApiRequest-TE5WD3WS.js +26 -0
- package/dist/{JsonApiRequest-G625QAH5.js.map → JsonApiRequest-TE5WD3WS.js.map} +1 -1
- package/dist/{JsonApiRequest-RRMNV7YE.mjs → JsonApiRequest-UHOWNI3O.mjs} +2 -2
- package/dist/analytics.modules-CfczU12P.d.ts +25 -0
- package/dist/analytics.modules-pzGwMASI.d.mts +25 -0
- package/dist/{auth.interface-BC1jp_ni.d.ts → auth.interface-BzI4onI_.d.ts} +1 -1
- package/dist/{auth.interface-dR2-6gPM.d.mts → auth.interface-Df4vXbNz.d.mts} +1 -1
- package/dist/billing/index.js +402 -402
- package/dist/billing/index.mjs +4 -4
- package/dist/{chunk-YXRVPEEJ.js → chunk-26MQIJNB.js} +1 -1
- package/dist/{chunk-YXRVPEEJ.js.map → chunk-26MQIJNB.js.map} +1 -1
- package/dist/{chunk-T76WLDE5.mjs → chunk-37DMFVLN.mjs} +1 -1
- package/dist/{chunk-3AR4KA5D.js → chunk-4Z45NYPR.js} +13 -13
- package/dist/{chunk-3AR4KA5D.js.map → chunk-4Z45NYPR.js.map} +1 -1
- package/dist/chunk-7B4E42R5.mjs +280 -0
- package/dist/chunk-7B4E42R5.mjs.map +1 -0
- package/dist/{chunk-2XCUAHQT.js → chunk-ABVTYQIJ.js} +1563 -1336
- package/dist/chunk-ABVTYQIJ.js.map +1 -0
- package/dist/{chunk-U7QVISQS.js → chunk-CAKOFJZD.js} +450 -40
- package/dist/chunk-CAKOFJZD.js.map +1 -0
- package/dist/{chunk-IBVKPLHT.mjs → chunk-E3I4MT53.mjs} +422 -12
- package/dist/chunk-E3I4MT53.mjs.map +1 -0
- package/dist/chunk-GHFMKH3M.js +280 -0
- package/dist/chunk-GHFMKH3M.js.map +1 -0
- package/dist/chunk-GKDHYB2R.js +110 -0
- package/dist/chunk-GKDHYB2R.js.map +1 -0
- package/dist/{chunk-ZJI5ZZW5.mjs → chunk-Q6J2ICKO.mjs} +3 -3
- package/dist/{chunk-4O7R2Z2O.mjs → chunk-QWZAGQWJ.mjs} +2175 -1948
- package/dist/chunk-QWZAGQWJ.mjs.map +1 -0
- package/dist/chunk-U65JPKO3.mjs +110 -0
- package/dist/chunk-U65JPKO3.mjs.map +1 -0
- package/dist/client/index.d.mts +6 -5
- package/dist/client/index.d.ts +6 -5
- package/dist/client/index.js +5 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +4 -4
- package/dist/components/index.d.mts +33 -10
- package/dist/components/index.d.ts +33 -10
- package/dist/components/index.js +7 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +6 -4
- package/dist/{config-B6UB7j69.d.ts → config-3N1LSrcC.d.ts} +1 -1
- package/dist/{config-Cd5xTEfZ.d.ts → config-AVtuvfM1.d.ts} +1 -1
- package/dist/{config-n-ZpPmOL.d.mts → config-BSKTYDoh.d.mts} +1 -1
- package/dist/{config-YyhFHxkU.d.mts → config-BmufyEj6.d.mts} +1 -1
- package/dist/contexts/index.d.mts +5 -4
- package/dist/contexts/index.d.ts +5 -4
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +31 -14
- package/dist/core/index.d.ts +31 -14
- package/dist/core/index.js +9 -5
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +8 -4
- package/dist/features/analytics/index.css +30 -0
- package/dist/features/analytics/index.css.map +1 -0
- package/dist/features/analytics/index.d.mts +508 -0
- package/dist/features/analytics/index.d.ts +508 -0
- package/dist/features/analytics/index.js +1166 -0
- package/dist/features/analytics/index.js.map +1 -0
- package/dist/features/analytics/index.mjs +1166 -0
- package/dist/features/analytics/index.mjs.map +1 -0
- package/dist/features/analytics/server.d.mts +28 -0
- package/dist/features/analytics/server.d.ts +28 -0
- package/dist/features/analytics/server.js +41 -0
- package/dist/features/analytics/server.js.map +1 -0
- package/dist/features/analytics/server.mjs +41 -0
- package/dist/features/analytics/server.mjs.map +1 -0
- package/dist/features/help/index.d.mts +2 -2
- package/dist/features/help/index.d.ts +2 -2
- package/dist/features/help/index.js +43 -43
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +9 -9
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/tokenusage/index.d.mts +2 -2
- package/dist/features/tokenusage/index.d.ts +2 -2
- package/dist/features/tokenusage/index.js +141 -223
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +28 -110
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +11 -9
- package/dist/index.d.ts +11 -9
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -5
- package/dist/notification.interface-BNCvnVCW.d.ts +34 -0
- package/dist/notification.interface-P_pNfzZQ.d.mts +34 -0
- package/dist/{s3.service-Bqr-fQmi.d.ts → s3.service-CbUa6a-8.d.ts} +3 -2
- package/dist/{s3.service-Buiy1ncj.d.mts → s3.service-DAp8FbuG.d.mts} +3 -2
- package/dist/server/index.d.mts +5 -4
- package/dist/server/index.d.ts +5 -4
- package/dist/server/index.js +39 -279
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +20 -260
- package/dist/server/index.mjs.map +1 -1
- package/dist/{types-bsm8Fp55.d.ts → types-Cn153-mH.d.ts} +1 -1
- package/dist/{types-Bq_UA8Lg.d.mts → types-D0uLYV2P.d.mts} +1 -1
- package/dist/{usePageUrlGenerator-J_ECv-oQ.d.ts → usePageUrlGenerator-B-3FRKBD.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-uKBjAKqC.d.mts → usePageUrlGenerator-CCJ_cdQy.d.mts} +1 -1
- package/dist/{useSocket-BcH7l7B8.d.mts → useSocket-B9gTbuCj.d.mts} +1 -1
- package/dist/{useSocket-DCZd_eJk.d.ts → useSocket-BHVIyigI.d.ts} +1 -1
- package/dist/{notification.interface-MnRt1Tjm.d.mts → user.interface-CSaGWFbg.d.mts} +1 -31
- package/dist/{notification.interface-CjzV_KII.d.ts → user.interface-CrmwJLYW.d.ts} +1 -31
- package/package.json +11 -1
- package/src/client/index.ts +2 -0
- package/src/components/containers/PageContainer.tsx +1 -1
- package/src/components/containers/RoundPageContainer.tsx +60 -16
- package/src/components/containers/RoundPageContainerTitle.tsx +75 -35
- package/src/components/editors/BlockNoteEditor.tsx +13 -1
- package/src/components/forms/EntityMultiSelector.tsx +4 -4
- package/src/components/forms/EntitySelector.tsx +4 -4
- package/src/components/forms/FormBlockNote.tsx +21 -1
- package/src/components/forms/FormCheckbox.tsx +6 -2
- package/src/components/forms/__tests__/useEditorDialog.test.ts +47 -0
- package/src/components/forms/useEditorDialog.ts +22 -0
- package/src/components/navigations/Breadcrumb.tsx +45 -16
- package/src/components/navigations/Header.tsx +16 -3
- package/src/components/navigations/MobileNavigationBar.tsx +12 -0
- package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +17 -0
- package/src/components/tables/ContentListTable.tsx +52 -32
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +7 -0
- package/src/features/__tests__/i18n-keys.contract.spec.ts +1 -0
- package/src/features/analytics/__tests__/server-entry.spec.tsx +64 -0
- package/src/features/analytics/analytics.modules.ts +30 -0
- package/src/features/analytics/components/AnalyticsAdminContainer.tsx +97 -0
- package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +120 -0
- package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +25 -0
- package/src/features/analytics/components/AnalyticsAdminTiles.tsx +191 -0
- package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +105 -0
- package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +246 -0
- package/src/features/analytics/components/AnalyticsJourneyPageContainer.tsx +14 -0
- package/src/features/analytics/components/AnalyticsSessionsTable.tsx +107 -0
- package/src/features/analytics/components/AnalyticsTimelineChart.tsx +175 -0
- package/src/features/analytics/components/AnalyticsTracker.tsx +112 -0
- package/src/features/analytics/components/__tests__/AnalyticsAdminTiles.spec.tsx +39 -0
- package/src/features/analytics/components/__tests__/AnalyticsBreakdownTable.spec.tsx +39 -0
- package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +124 -0
- package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +117 -0
- package/src/features/analytics/components/__tests__/AnalyticsSessionsTable.spec.tsx +121 -0
- package/src/features/analytics/components/__tests__/AnalyticsTimelineChart.spec.tsx +35 -0
- package/src/features/analytics/components/__tests__/AnalyticsTracker.spec.tsx +248 -0
- package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +28 -0
- package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +25 -0
- package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +273 -0
- package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +140 -0
- package/src/features/analytics/data/AnalyticsAdminService.ts +91 -0
- package/src/features/analytics/data/AnalyticsService.ts +23 -0
- package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +117 -0
- package/src/features/analytics/data/__tests__/AnalyticsService.spec.ts +56 -0
- package/src/features/analytics/data/__tests__/models.spec.ts +300 -0
- package/src/features/analytics/data/analytics-breakdown.interface.ts +9 -0
- package/src/features/analytics/data/analytics-breakdown.ts +65 -0
- package/src/features/analytics/data/analytics-event.interface.ts +7 -0
- package/src/features/analytics/data/analytics-event.ts +63 -0
- package/src/features/analytics/data/analytics-page-view.interface.ts +9 -0
- package/src/features/analytics/data/analytics-page-view.ts +57 -0
- package/src/features/analytics/data/analytics-session.interface.ts +23 -0
- package/src/features/analytics/data/analytics-session.ts +144 -0
- package/src/features/analytics/data/analytics-summary.interface.ts +11 -0
- package/src/features/analytics/data/analytics-summary.ts +79 -0
- package/src/features/analytics/data/analytics-timeline.interface.ts +10 -0
- package/src/features/analytics/data/analytics-timeline.ts +67 -0
- package/src/features/analytics/data/analytics.types.ts +97 -0
- package/src/features/analytics/data/index.ts +15 -0
- package/src/features/analytics/i18n-keys.ts +76 -0
- package/src/features/analytics/index.ts +31 -0
- package/src/features/analytics/lib/__tests__/utm.spec.ts +20 -0
- package/src/features/analytics/lib/__tests__/visitorCookie.spec.ts +63 -0
- package/src/features/analytics/lib/utm.ts +29 -0
- package/src/features/analytics/lib/visitorCookie.ts +32 -0
- package/src/features/analytics/server-entry.ts +8 -0
- package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +1 -1
- package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
- package/src/features/assistant-message/components/MessageItem.tsx +2 -2
- package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
- package/src/features/auth/components/containers/AuthContainer.tsx +8 -1
- package/src/features/company/components/details/CompanyContent.tsx +3 -1
- package/src/features/company/components/details/CompanyDetails.tsx +9 -1
- package/src/features/company/components/details/TokenStatusIndicator.tsx +42 -13
- package/src/features/help/components/HelpHeader.tsx +7 -5
- package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +9 -6
- package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +21 -0
- package/src/features/tokenusage/lib/formatters.ts +5 -1
- package/src/features/waitlist/components/lists/WaitlistList.tsx +68 -121
- package/src/features/waitlist/data/WaitlistService.ts +2 -2
- package/src/features/waitlist/data/index.ts +1 -0
- package/src/features/waitlist/data/waitlist.fields.ts +8 -0
- package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +69 -30
- package/src/shadcnui/ui/button.tsx +1 -1
- package/src/shadcnui/ui/checkbox.tsx +1 -1
- package/src/shadcnui/ui/command.tsx +4 -4
- package/src/shadcnui/ui/dialog.tsx +2 -0
- package/src/shadcnui/ui/input.tsx +1 -1
- package/src/shadcnui/ui/popover.tsx +9 -0
- package/src/shadcnui/ui/select.tsx +1 -1
- package/src/shadcnui/ui/sheet.tsx +8 -1
- package/src/shadcnui/ui/table.tsx +2 -2
- package/src/shadcnui/ui/textarea.tsx +1 -1
- package/dist/BlockNoteEditor-35D4R7HJ.js.map +0 -1
- package/dist/JsonApiRequest-G625QAH5.js +0 -26
- package/dist/chunk-2XCUAHQT.js.map +0 -1
- package/dist/chunk-4O7R2Z2O.mjs.map +0 -1
- package/dist/chunk-IBVKPLHT.mjs.map +0 -1
- package/dist/chunk-U7QVISQS.js.map +0 -1
- /package/dist/{BlockNoteViewer-KDKGUIG5.mjs.map → BlockNoteViewer-KRTAFTUX.mjs.map} +0 -0
- /package/dist/{JsonApiRequest-RRMNV7YE.mjs.map → JsonApiRequest-UHOWNI3O.mjs.map} +0 -0
- /package/dist/{chunk-T76WLDE5.mjs.map → chunk-37DMFVLN.mjs.map} +0 -0
- /package/dist/{chunk-ZJI5ZZW5.mjs.map → chunk-Q6J2ICKO.mjs.map} +0 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
};
|