@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,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { MicroLabel } from "../../../components";
|
|
6
|
+
import { Card, CardContent } from "../../../shadcnui";
|
|
7
|
+
import { cn } from "../../../utils";
|
|
8
|
+
import { useUsageFormatters } from "../../tokenusage/lib/formatters";
|
|
9
|
+
import { percentageDelta } from "../../tokenusage/lib/metrics";
|
|
10
|
+
import type { AnalyticsSummaryInterface } from "../data/analytics-summary.interface";
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
/** The six summary rows: {public, app, total} × {current, previous}. */
|
|
14
|
+
summary: AnalyticsSummaryInterface[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type SummaryMetrics = Pick<
|
|
18
|
+
AnalyticsSummaryInterface,
|
|
19
|
+
"visitors" | "sessions" | "pageViews" | "pagesPerSession" | "consentShare"
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
const ZERO: SummaryMetrics = { visitors: 0, sessions: 0, pageViews: 0, pagesPerSession: 0, consentShare: 0 };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The KPI header of the administrative analytics page.
|
|
26
|
+
*
|
|
27
|
+
* Three lead tiles carry the traffic volumes — visitors, sessions, page views —
|
|
28
|
+
* each with its delta against the equal-length preceding window. Two supporting
|
|
29
|
+
* tiles below carry the ratios that give those numbers context.
|
|
30
|
+
*
|
|
31
|
+
* The tiles read the `total` row: the backend already applies the section filter
|
|
32
|
+
* to it, so it is the figure for whatever the filter bar selected. The backend
|
|
33
|
+
* always returns both windows, which is why no tile has to branch on a missing
|
|
34
|
+
* row: an absent one is simply zero-filled here.
|
|
35
|
+
*/
|
|
36
|
+
export function AnalyticsAdminTiles({ summary }: Props) {
|
|
37
|
+
const t = useTranslations();
|
|
38
|
+
const { decimal } = useUsageFormatters();
|
|
39
|
+
|
|
40
|
+
const rowFor = (window: string): SummaryMetrics =>
|
|
41
|
+
summary.find((r) => r.section === "total" && r.window === window) ?? ZERO;
|
|
42
|
+
|
|
43
|
+
const current = rowFor("current");
|
|
44
|
+
const previous = rowFor("previous");
|
|
45
|
+
|
|
46
|
+
const previousLabel = t("analytics.admin.tiles.vs_previous");
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="grid gap-3">
|
|
50
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
51
|
+
<LeadTile
|
|
52
|
+
testId="analytics-tile-visitors"
|
|
53
|
+
label={t("analytics.admin.tiles.visitors")}
|
|
54
|
+
value={decimal(current.visitors, 0)}
|
|
55
|
+
delta={percentageDelta(current.visitors, previous.visitors)}
|
|
56
|
+
previousLabel={previousLabel}
|
|
57
|
+
decimal={decimal}
|
|
58
|
+
/>
|
|
59
|
+
<LeadTile
|
|
60
|
+
testId="analytics-tile-sessions"
|
|
61
|
+
label={t("analytics.admin.tiles.sessions")}
|
|
62
|
+
value={decimal(current.sessions, 0)}
|
|
63
|
+
delta={percentageDelta(current.sessions, previous.sessions)}
|
|
64
|
+
previousLabel={previousLabel}
|
|
65
|
+
decimal={decimal}
|
|
66
|
+
/>
|
|
67
|
+
<LeadTile
|
|
68
|
+
testId="analytics-tile-page-views"
|
|
69
|
+
label={t("analytics.admin.tiles.page_views")}
|
|
70
|
+
value={decimal(current.pageViews, 0)}
|
|
71
|
+
delta={percentageDelta(current.pageViews, previous.pageViews)}
|
|
72
|
+
previousLabel={previousLabel}
|
|
73
|
+
decimal={decimal}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
78
|
+
<SupportingTile
|
|
79
|
+
testId="analytics-tile-pages-per-session"
|
|
80
|
+
label={t("analytics.admin.tiles.pages_per_session")}
|
|
81
|
+
value={decimal(current.pagesPerSession, 1)}
|
|
82
|
+
delta={percentageDelta(current.pagesPerSession, previous.pagesPerSession)}
|
|
83
|
+
decimal={decimal}
|
|
84
|
+
/>
|
|
85
|
+
<SupportingTile
|
|
86
|
+
testId="analytics-tile-consent-share"
|
|
87
|
+
label={t("analytics.admin.tiles.consent_share")}
|
|
88
|
+
// consentShare is a 0..1 ratio on the wire.
|
|
89
|
+
value={`${decimal(current.consentShare * 100, 0)}%`}
|
|
90
|
+
decimal={decimal}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function LeadTile({
|
|
98
|
+
testId,
|
|
99
|
+
label,
|
|
100
|
+
value,
|
|
101
|
+
delta,
|
|
102
|
+
previousLabel,
|
|
103
|
+
decimal,
|
|
104
|
+
}: {
|
|
105
|
+
testId: string;
|
|
106
|
+
label: string;
|
|
107
|
+
value: string;
|
|
108
|
+
delta: number | undefined;
|
|
109
|
+
previousLabel: string;
|
|
110
|
+
/** Passed down because `Delta` is a plain function, not a component: it cannot call the hook itself. */
|
|
111
|
+
decimal: (value: number, decimals: number) => string;
|
|
112
|
+
}) {
|
|
113
|
+
return (
|
|
114
|
+
<Card data-testid={testId}>
|
|
115
|
+
<CardContent className="grid gap-1">
|
|
116
|
+
<MicroLabel>{label}</MicroLabel>
|
|
117
|
+
<span className="text-primary text-xl font-semibold tabular-nums">{value}</span>
|
|
118
|
+
<span className="flex items-center gap-1">
|
|
119
|
+
<Delta testId={`${testId}-delta`} delta={delta} decimal={decimal} />
|
|
120
|
+
<span className="text-muted-foreground text-xs">{previousLabel}</span>
|
|
121
|
+
</span>
|
|
122
|
+
</CardContent>
|
|
123
|
+
</Card>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function SupportingTile({
|
|
128
|
+
testId,
|
|
129
|
+
label,
|
|
130
|
+
value,
|
|
131
|
+
delta,
|
|
132
|
+
decimal,
|
|
133
|
+
}: {
|
|
134
|
+
testId: string;
|
|
135
|
+
label: string;
|
|
136
|
+
value: string;
|
|
137
|
+
delta?: number | undefined;
|
|
138
|
+
/** Passed down because `Delta` is a plain function, not a component: it cannot call the hook itself. */
|
|
139
|
+
decimal: (value: number, decimals: number) => string;
|
|
140
|
+
}) {
|
|
141
|
+
return (
|
|
142
|
+
<Card data-testid={testId} size="sm">
|
|
143
|
+
<CardContent className="grid gap-1">
|
|
144
|
+
<MicroLabel>{label}</MicroLabel>
|
|
145
|
+
<span className="flex items-center gap-2">
|
|
146
|
+
<span className="text-sm font-medium tabular-nums">{value}</span>
|
|
147
|
+
{delta !== undefined && <Delta testId={`${testId}-delta`} delta={delta} decimal={decimal} />}
|
|
148
|
+
</span>
|
|
149
|
+
</CardContent>
|
|
150
|
+
</Card>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The delta slot. An undefined delta means the previous window was zero: an
|
|
156
|
+
* em dash says "not comparable" where a percentage would say "infinite growth".
|
|
157
|
+
*/
|
|
158
|
+
function Delta({
|
|
159
|
+
testId,
|
|
160
|
+
delta,
|
|
161
|
+
decimal,
|
|
162
|
+
}: {
|
|
163
|
+
testId: string;
|
|
164
|
+
delta: number | undefined;
|
|
165
|
+
decimal: (value: number, decimals: number) => string;
|
|
166
|
+
}) {
|
|
167
|
+
if (delta === undefined) {
|
|
168
|
+
return (
|
|
169
|
+
<span data-testid={testId} className="text-muted-foreground text-xs">
|
|
170
|
+
—
|
|
171
|
+
</span>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const increased = delta >= 0;
|
|
176
|
+
const Icon = increased ? ArrowUpIcon : ArrowDownIcon;
|
|
177
|
+
const sign = delta > 0 ? "+" : delta < 0 ? "-" : "";
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<span
|
|
181
|
+
data-testid={testId}
|
|
182
|
+
className={cn(
|
|
183
|
+
"inline-flex items-center gap-0.5 text-xs tabular-nums",
|
|
184
|
+
increased ? "text-success" : "text-destructive",
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
<Icon aria-hidden className="size-3" />
|
|
188
|
+
{`${sign}${decimal(Math.abs(delta), 0)}%`}
|
|
189
|
+
</span>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
import {
|
|
6
|
+
Table,
|
|
7
|
+
TableBody,
|
|
8
|
+
TableCell,
|
|
9
|
+
TableHead,
|
|
10
|
+
TableHeader,
|
|
11
|
+
TableRow,
|
|
12
|
+
Tabs,
|
|
13
|
+
TabsContent,
|
|
14
|
+
TabsList,
|
|
15
|
+
TabsTrigger,
|
|
16
|
+
} from "../../../shadcnui";
|
|
17
|
+
import { useUsageFormatters } from "../../tokenusage/lib/formatters";
|
|
18
|
+
import type { AnalyticsBreakdownInterface } from "../data/analytics-breakdown.interface";
|
|
19
|
+
import { ANALYTICS_DIMENSIONS, type AnalyticsDimension } from "../data/analytics.types";
|
|
20
|
+
|
|
21
|
+
type Props = {
|
|
22
|
+
breakdowns: Record<AnalyticsDimension, AnalyticsBreakdownInterface[]>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The key the repository folds the tail past the limit into. */
|
|
26
|
+
const OTHER_KEY = "other";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Attribution and content breakdowns, one tab per dimension.
|
|
30
|
+
*
|
|
31
|
+
* Each tab is the same four-column table — key, visitors, sessions, page views —
|
|
32
|
+
* sorted by visitors. Sorting is client-side because the whole ranked set is
|
|
33
|
+
* already in memory, so a round trip would buy nothing. Keys are data, not
|
|
34
|
+
* vocabulary: `(none)` and the routes are rendered as the backend wrote them;
|
|
35
|
+
* only the `other` rollup is translated.
|
|
36
|
+
*/
|
|
37
|
+
export function AnalyticsBreakdownTable({ breakdowns }: Props) {
|
|
38
|
+
const t = useTranslations();
|
|
39
|
+
|
|
40
|
+
const labels: Record<AnalyticsDimension, string> = {
|
|
41
|
+
source: t("analytics.admin.dimensions.source"),
|
|
42
|
+
medium: t("analytics.admin.dimensions.medium"),
|
|
43
|
+
campaign: t("analytics.admin.dimensions.campaign"),
|
|
44
|
+
referrer: t("analytics.admin.dimensions.referrer"),
|
|
45
|
+
route: t("analytics.admin.dimensions.route"),
|
|
46
|
+
landing: t("analytics.admin.dimensions.landing"),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Tabs defaultValue={ANALYTICS_DIMENSIONS[0]}>
|
|
51
|
+
<TabsList>
|
|
52
|
+
{ANALYTICS_DIMENSIONS.map((dimension) => (
|
|
53
|
+
<TabsTrigger key={dimension} value={dimension}>
|
|
54
|
+
{labels[dimension]}
|
|
55
|
+
</TabsTrigger>
|
|
56
|
+
))}
|
|
57
|
+
</TabsList>
|
|
58
|
+
{ANALYTICS_DIMENSIONS.map((dimension) => (
|
|
59
|
+
<TabsContent key={dimension} value={dimension}>
|
|
60
|
+
<BreakdownRows rows={breakdowns[dimension] ?? []} />
|
|
61
|
+
</TabsContent>
|
|
62
|
+
))}
|
|
63
|
+
</Tabs>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function BreakdownRows({ rows }: { rows: AnalyticsBreakdownInterface[] }) {
|
|
68
|
+
const t = useTranslations();
|
|
69
|
+
const { decimal } = useUsageFormatters();
|
|
70
|
+
|
|
71
|
+
const sortedRows = useMemo(() => [...rows].sort((a, b) => b.visitors - a.visitors), [rows]);
|
|
72
|
+
|
|
73
|
+
if (sortedRows.length === 0) {
|
|
74
|
+
return <p className="text-muted-foreground text-sm">{t("analytics.admin.no_data")}</p>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
// The package's <Table> wraps itself in an `overflow-x-clip` container, which
|
|
79
|
+
// would swallow the overflow before this scroller ever saw it — hence the
|
|
80
|
+
// child override. Wide tables must scroll, never widen the page.
|
|
81
|
+
<div className="overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible">
|
|
82
|
+
<Table>
|
|
83
|
+
<TableHeader>
|
|
84
|
+
<TableRow>
|
|
85
|
+
<TableHead className="text-xs font-medium">{t("analytics.admin.columns.key")}</TableHead>
|
|
86
|
+
<TableHead className="text-end text-xs font-medium">{t("analytics.admin.columns.visitors")}</TableHead>
|
|
87
|
+
<TableHead className="text-end text-xs font-medium">{t("analytics.admin.columns.sessions")}</TableHead>
|
|
88
|
+
<TableHead className="text-end text-xs font-medium">{t("analytics.admin.columns.page_views")}</TableHead>
|
|
89
|
+
</TableRow>
|
|
90
|
+
</TableHeader>
|
|
91
|
+
<TableBody>
|
|
92
|
+
{sortedRows.map((row) => (
|
|
93
|
+
<TableRow key={row.id} data-testid={`analytics-breakdown-row-${row.id}`}>
|
|
94
|
+
<TableCell className="text-xs">{row.key === OTHER_KEY ? t("analytics.admin.other") : row.key}</TableCell>
|
|
95
|
+
<TableCell className="text-end text-xs tabular-nums">{decimal(row.visitors, 0)}</TableCell>
|
|
96
|
+
<TableCell className="text-end text-xs tabular-nums">{decimal(row.sessions, 0)}</TableCell>
|
|
97
|
+
<TableCell className="text-end text-xs tabular-nums">{decimal(row.pageViews, 0)}</TableCell>
|
|
98
|
+
</TableRow>
|
|
99
|
+
))}
|
|
100
|
+
</TableBody>
|
|
101
|
+
</Table>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|