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