@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.
- 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-65HR6ULV.mjs → BlockNoteEditor-46O3JC4Z.mjs} +5 -5
- package/dist/{BlockNoteEditor-35D4R7HJ.js → BlockNoteEditor-HKS6AXDA.js} +20 -20
- package/dist/{BlockNoteEditor-35D4R7HJ.js.map → BlockNoteEditor-HKS6AXDA.js.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-4Z2UONN2.mjs +110 -0
- package/dist/chunk-4Z2UONN2.mjs.map +1 -0
- 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-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-KLV6RLDE.js +110 -0
- package/dist/chunk-KLV6RLDE.js.map +1 -0
- package/dist/{chunk-ZJI5ZZW5.mjs → chunk-Q6J2ICKO.mjs} +3 -3
- package/dist/{chunk-4O7R2Z2O.mjs → chunk-XDYILJ2S.mjs} +1749 -1737
- package/dist/{chunk-4O7R2Z2O.mjs.map → chunk-XDYILJ2S.mjs.map} +1 -1
- package/dist/{chunk-2XCUAHQT.js → chunk-Y3OKEQGL.js} +1126 -1114
- package/dist/chunk-Y3OKEQGL.js.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 +17 -7
- package/dist/components/index.d.ts +17 -7
- package/dist/components/index.js +5 -5
- package/dist/components/index.mjs +4 -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 +38 -38
- package/dist/features/help/index.mjs +4 -4
- package/dist/features/tokenusage/index.d.mts +2 -2
- package/dist/features/tokenusage/index.d.ts +2 -2
- package/dist/features/tokenusage/index.js +139 -221
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +26 -108
- 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/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 +117 -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 +104 -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/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/dist/JsonApiRequest-G625QAH5.js +0 -26
- package/dist/chunk-2XCUAHQT.js.map +0 -1
- package/dist/chunk-IBVKPLHT.mjs.map +0 -1
- package/dist/chunk-U7QVISQS.js.map +0 -1
- /package/dist/{BlockNoteEditor-65HR6ULV.mjs.map → BlockNoteEditor-46O3JC4Z.mjs.map} +0 -0
- /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,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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { AnalyticsAdminTiles } from "../AnalyticsAdminTiles";
|
|
4
|
+
|
|
5
|
+
const row = (section: string, window: string, values: Partial<Record<string, number>>) =>
|
|
6
|
+
({
|
|
7
|
+
id: `${section}|${window}`,
|
|
8
|
+
section,
|
|
9
|
+
window,
|
|
10
|
+
visitors: 0,
|
|
11
|
+
sessions: 0,
|
|
12
|
+
pageViews: 0,
|
|
13
|
+
pagesPerSession: 0,
|
|
14
|
+
consentShare: 0,
|
|
15
|
+
...values,
|
|
16
|
+
}) as any;
|
|
17
|
+
|
|
18
|
+
const summary = [
|
|
19
|
+
row("total", "current", { visitors: 120, sessions: 150, pageViews: 600, pagesPerSession: 4, consentShare: 0.25 }),
|
|
20
|
+
row("total", "previous", { visitors: 100 }),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
describe("AnalyticsAdminTiles", () => {
|
|
24
|
+
it("shows the current totals with the delta against the previous window", () => {
|
|
25
|
+
render(<AnalyticsAdminTiles summary={summary} />);
|
|
26
|
+
|
|
27
|
+
expect(screen.getByTestId("analytics-tile-visitors")).toHaveTextContent("120");
|
|
28
|
+
expect(screen.getByTestId("analytics-tile-visitors-delta")).toHaveTextContent("+20%");
|
|
29
|
+
expect(screen.getByTestId("analytics-tile-consent-share")).toHaveTextContent("25%");
|
|
30
|
+
expect(screen.getByTestId("analytics-tile-pages-per-session")).toHaveTextContent("4.0");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("suppresses the delta rather than showing Infinity when the previous window is zero", () => {
|
|
34
|
+
render(<AnalyticsAdminTiles summary={summary} />);
|
|
35
|
+
|
|
36
|
+
// sessions previous is 0
|
|
37
|
+
expect(screen.getByTestId("analytics-tile-sessions-delta")).toHaveTextContent("—");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { AnalyticsBreakdownTable } from "../AnalyticsBreakdownTable";
|
|
4
|
+
|
|
5
|
+
const row = (dimension: string, key: string, visitors: number) =>
|
|
6
|
+
({ id: `${dimension}|${key}`, dimension, key, visitors, sessions: visitors, pageViews: visitors * 2 }) as any;
|
|
7
|
+
|
|
8
|
+
const breakdowns = {
|
|
9
|
+
source: [row("source", "(none)", 5), row("source", "linkedin", 30), row("source", "google", 12)],
|
|
10
|
+
medium: [],
|
|
11
|
+
campaign: [],
|
|
12
|
+
referrer: [],
|
|
13
|
+
route: [row("route", "/rolls/:id", 9)],
|
|
14
|
+
landing: [],
|
|
15
|
+
} as any;
|
|
16
|
+
|
|
17
|
+
describe("AnalyticsBreakdownTable", () => {
|
|
18
|
+
it("renders one tab per dimension", () => {
|
|
19
|
+
render(<AnalyticsBreakdownTable breakdowns={breakdowns} />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getAllByRole("tab")).toHaveLength(6);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("sorts rows by visitors descending and renders the (none) key as-is", () => {
|
|
25
|
+
render(<AnalyticsBreakdownTable breakdowns={breakdowns} />);
|
|
26
|
+
|
|
27
|
+
const rows = screen.getAllByTestId(/^analytics-breakdown-row-/);
|
|
28
|
+
expect(rows.map((r) => within(r).getAllByRole("cell")[0].textContent)).toEqual(["linkedin", "google", "(none)"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("shows the selected dimension's rows when its tab is clicked", () => {
|
|
32
|
+
render(<AnalyticsBreakdownTable breakdowns={breakdowns} />);
|
|
33
|
+
|
|
34
|
+
fireEvent.click(screen.getByRole("tab", { name: "analytics.admin.dimensions.route" }));
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText("/rolls/:id")).toBeInTheDocument();
|
|
37
|
+
expect(screen.queryByText("linkedin")).not.toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { configureI18n } from "../../../../i18n";
|
|
4
|
+
import { AnalyticsAdminService } from "../../data/AnalyticsAdminService";
|
|
5
|
+
import { AnalyticsJourneyContainer } from "../AnalyticsJourneyContainer";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../data/AnalyticsAdminService", () => ({
|
|
8
|
+
AnalyticsAdminService: {
|
|
9
|
+
getSession: vi.fn(),
|
|
10
|
+
getSessionPageViews: vi.fn(),
|
|
11
|
+
getSessions: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// The real page chrome needs the whole app shell (header, sidebar, navigation
|
|
16
|
+
// contexts); the journey's own content is what is under test here.
|
|
17
|
+
vi.mock("../../../../components", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("../../../../components")>();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
RoundPageContainer: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
configureI18n({
|
|
27
|
+
useRouter: () => ({
|
|
28
|
+
push: vi.fn(),
|
|
29
|
+
replace: vi.fn(),
|
|
30
|
+
back: vi.fn(),
|
|
31
|
+
forward: vi.fn(),
|
|
32
|
+
refresh: vi.fn(),
|
|
33
|
+
prefetch: vi.fn(),
|
|
34
|
+
}),
|
|
35
|
+
useTranslations: () => (key: string) => key,
|
|
36
|
+
usePathname: () => "/",
|
|
37
|
+
Link: ({
|
|
38
|
+
href,
|
|
39
|
+
children,
|
|
40
|
+
prefetch: _prefetch,
|
|
41
|
+
...rest
|
|
42
|
+
}: {
|
|
43
|
+
href: string;
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
[key: string]: any;
|
|
46
|
+
}) => (
|
|
47
|
+
<a href={href} {...rest}>
|
|
48
|
+
{children}
|
|
49
|
+
</a>
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const session = (id: string, startedAt: string) =>
|
|
55
|
+
({
|
|
56
|
+
id,
|
|
57
|
+
visitorId: "visitor-0000-0000-abc123",
|
|
58
|
+
consented: true,
|
|
59
|
+
startedAt: new Date(startedAt),
|
|
60
|
+
lastSeenAt: new Date(startedAt),
|
|
61
|
+
pageViews: 3,
|
|
62
|
+
section: "public",
|
|
63
|
+
landingRoute: "/pricing",
|
|
64
|
+
referrerHost: "linkedin.com",
|
|
65
|
+
utmSource: "linkedin",
|
|
66
|
+
utmMedium: "social",
|
|
67
|
+
utmCampaign: "launch",
|
|
68
|
+
deviceType: "desktop",
|
|
69
|
+
}) as any;
|
|
70
|
+
|
|
71
|
+
const pageView = (id: string, createdAt: string, path: string) =>
|
|
72
|
+
({ id, createdAt: new Date(createdAt), path, route: path, section: "public" }) as any;
|
|
73
|
+
|
|
74
|
+
describe("AnalyticsJourneyContainer", () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
vi.mocked(AnalyticsAdminService.getSession).mockResolvedValue(session("s1", "2026-10-01T10:00:00.000Z"));
|
|
78
|
+
vi.mocked(AnalyticsAdminService.getSessionPageViews).mockResolvedValue([
|
|
79
|
+
pageView("p1", "2026-10-01T10:00:00.000Z", "/pricing"),
|
|
80
|
+
pageView("p2", "2026-10-01T10:02:00.000Z", "/features"),
|
|
81
|
+
pageView("p3", "2026-10-01T10:02:30.000Z", "/register"),
|
|
82
|
+
]);
|
|
83
|
+
vi.mocked(AnalyticsAdminService.getSessions).mockResolvedValue([
|
|
84
|
+
session("s1", "2026-10-01T10:00:00.000Z"),
|
|
85
|
+
session("s9", "2026-09-20T08:00:00.000Z"),
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("renders the page views in order with the gap to the previous one", async () => {
|
|
90
|
+
render(<AnalyticsJourneyContainer sessionId="s1" />);
|
|
91
|
+
|
|
92
|
+
const rows = await screen.findAllByTestId(/^analytics-pageview-row-/);
|
|
93
|
+
expect(rows.map((row) => within(row).getAllByRole("cell")[2].textContent)).toEqual([
|
|
94
|
+
"/pricing",
|
|
95
|
+
"/features",
|
|
96
|
+
"/register",
|
|
97
|
+
]);
|
|
98
|
+
expect(within(rows[1]).getByText("2 min")).toBeInTheDocument();
|
|
99
|
+
expect(AnalyticsAdminService.getSessionPageViews).toHaveBeenCalledWith({ sessionId: "s1" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders the attribution header", async () => {
|
|
103
|
+
render(<AnalyticsJourneyContainer sessionId="s1" />);
|
|
104
|
+
|
|
105
|
+
const header = await screen.findByTestId("analytics-journey-attribution");
|
|
106
|
+
expect(header).toHaveTextContent("linkedin");
|
|
107
|
+
expect(header).toHaveTextContent("analytics.admin.consented");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("lists the visitor's other sessions, excluding the current one", async () => {
|
|
111
|
+
render(<AnalyticsJourneyContainer sessionId="s1" />);
|
|
112
|
+
|
|
113
|
+
await waitFor(() =>
|
|
114
|
+
expect(AnalyticsAdminService.getSessions).toHaveBeenCalledWith(
|
|
115
|
+
expect.objectContaining({ visitorId: "visitor-0000-0000-abc123", section: "all" }),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
expect(await screen.findByTestId("analytics-session-link-s9")).toHaveAttribute(
|
|
119
|
+
"href",
|
|
120
|
+
"/administration/analytics/sessions/s9",
|
|
121
|
+
);
|
|
122
|
+
expect(screen.queryByTestId("analytics-session-link-s1")).not.toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { configureI18n } from "../../../../i18n";
|
|
4
|
+
import { AnalyticsAdminService } from "../../data/AnalyticsAdminService";
|
|
5
|
+
import { AnalyticsAdminPageContainer } from "../AnalyticsAdminPageContainer";
|
|
6
|
+
import { AnalyticsJourneyPageContainer } from "../AnalyticsJourneyPageContainer";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../data/AnalyticsAdminService", () => ({
|
|
9
|
+
AnalyticsAdminService: {
|
|
10
|
+
getSummary: vi.fn(),
|
|
11
|
+
getTimeline: vi.fn(),
|
|
12
|
+
getBreakdown: vi.fn(),
|
|
13
|
+
getSessions: vi.fn(),
|
|
14
|
+
getSession: vi.fn(),
|
|
15
|
+
getSessionPageViews: vi.fn(),
|
|
16
|
+
next: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// The real page chrome needs the whole app shell (header, sidebar, navigation
|
|
21
|
+
// contexts); the page content is what is under test here.
|
|
22
|
+
vi.mock("../../../../components", async (importOriginal) => {
|
|
23
|
+
const actual = await importOriginal<typeof import("../../../../components")>();
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
RoundPageContainer: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
configureI18n({
|
|
32
|
+
useRouter: () => ({
|
|
33
|
+
push: vi.fn(),
|
|
34
|
+
replace: vi.fn(),
|
|
35
|
+
back: vi.fn(),
|
|
36
|
+
forward: vi.fn(),
|
|
37
|
+
refresh: vi.fn(),
|
|
38
|
+
prefetch: vi.fn(),
|
|
39
|
+
}),
|
|
40
|
+
useTranslations: () => (key: string) => key,
|
|
41
|
+
usePathname: () => "/",
|
|
42
|
+
Link: ({
|
|
43
|
+
href,
|
|
44
|
+
children,
|
|
45
|
+
prefetch: _prefetch,
|
|
46
|
+
...rest
|
|
47
|
+
}: {
|
|
48
|
+
href: string;
|
|
49
|
+
children: React.ReactNode;
|
|
50
|
+
[key: string]: any;
|
|
51
|
+
}) => (
|
|
52
|
+
<a href={href} {...rest}>
|
|
53
|
+
{children}
|
|
54
|
+
</a>
|
|
55
|
+
),
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const session = (id: string, startedAt: string) =>
|
|
60
|
+
({
|
|
61
|
+
id,
|
|
62
|
+
visitorId: "visitor-0000-0000-abc123",
|
|
63
|
+
consented: true,
|
|
64
|
+
startedAt: new Date(startedAt),
|
|
65
|
+
lastSeenAt: new Date(startedAt),
|
|
66
|
+
pageViews: 3,
|
|
67
|
+
section: "public",
|
|
68
|
+
landingRoute: "/pricing",
|
|
69
|
+
referrerHost: "linkedin.com",
|
|
70
|
+
utmSource: "linkedin",
|
|
71
|
+
deviceType: "desktop",
|
|
72
|
+
}) as any;
|
|
73
|
+
|
|
74
|
+
describe("AnalyticsAdminPageContainer", () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
vi.mocked(AnalyticsAdminService.getSummary).mockResolvedValue([]);
|
|
78
|
+
vi.mocked(AnalyticsAdminService.getTimeline).mockResolvedValue([]);
|
|
79
|
+
vi.mocked(AnalyticsAdminService.getBreakdown).mockResolvedValue([]);
|
|
80
|
+
vi.mocked(AnalyticsAdminService.getSessions).mockResolvedValue([session("s1", "2026-10-01T10:00:00.000Z")]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("loads the dashboard and links each session under the given sessionPageUrl", async () => {
|
|
84
|
+
render(<AnalyticsAdminPageContainer pageUrl="/admin/stats" sessionPageUrl="/admin/stats/sessions" />);
|
|
85
|
+
|
|
86
|
+
expect(await screen.findByTestId("analytics-session-link-s1")).toHaveAttribute("href", "/admin/stats/sessions/s1");
|
|
87
|
+
expect(AnalyticsAdminService.getSummary).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("falls back to the provider's default routes", async () => {
|
|
91
|
+
render(<AnalyticsAdminPageContainer />);
|
|
92
|
+
|
|
93
|
+
expect(await screen.findByTestId("analytics-session-link-s1")).toHaveAttribute(
|
|
94
|
+
"href",
|
|
95
|
+
"/administration/analytics/sessions/s1",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("AnalyticsJourneyPageContainer", () => {
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.clearAllMocks();
|
|
103
|
+
vi.mocked(AnalyticsAdminService.getSession).mockResolvedValue(session("s1", "2026-10-01T10:00:00.000Z"));
|
|
104
|
+
vi.mocked(AnalyticsAdminService.getSessionPageViews).mockResolvedValue([]);
|
|
105
|
+
vi.mocked(AnalyticsAdminService.getSessions).mockResolvedValue([
|
|
106
|
+
session("s1", "2026-10-01T10:00:00.000Z"),
|
|
107
|
+
session("s9", "2026-09-20T08:00:00.000Z"),
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("loads the given session and links the other sessions under pageUrl", async () => {
|
|
112
|
+
render(<AnalyticsJourneyPageContainer sessionId="s1" pageUrl="/admin/stats" />);
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(AnalyticsAdminService.getSession).toHaveBeenCalledWith({ id: "s1" }));
|
|
115
|
+
expect(await screen.findByTestId("analytics-session-link-s9")).toHaveAttribute("href", "/admin/stats/sessions/s9");
|
|
116
|
+
});
|
|
117
|
+
});
|