@composius/payload-plugin-umami 0.3.1 → 1.0.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/exports/client.js +137 -39
- package/dist/exports/client.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/exports/client.js
CHANGED
|
@@ -76,7 +76,24 @@ var fr = {
|
|
|
76
76
|
|
|
77
77
|
// src/types.ts
|
|
78
78
|
var UMAMI_RANGES = ["24h", "7d", "30d", "90d"];
|
|
79
|
-
var DEFAULT_STATS = ["visitors", "
|
|
79
|
+
var DEFAULT_STATS = ["visitors", "visitorsPrev", "views", "viewsPrev"];
|
|
80
|
+
|
|
81
|
+
// src/components/DashboardSkeleton.tsx
|
|
82
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
83
|
+
var DashboardSkeleton = ({ label, statCount }) => /* @__PURE__ */ jsxs("div", { "aria-busy": "true", "aria-label": label, className: "umami-grid", role: "status", children: [
|
|
84
|
+
Array.from({ length: statCount }, (_, index) => /* @__PURE__ */ jsxs("div", { className: "umami-card umami-stat", children: [
|
|
85
|
+
/* @__PURE__ */ jsx("span", { className: "umami-stat__label umami-skeleton umami-skeleton--label", children: "\xA0" }),
|
|
86
|
+
/* @__PURE__ */ jsx("span", { className: "umami-stat__value umami-skeleton umami-skeleton--value", children: "\xA0" })
|
|
87
|
+
] }, index)),
|
|
88
|
+
/* @__PURE__ */ jsxs("div", { className: "umami-card umami-chart", children: [
|
|
89
|
+
/* @__PURE__ */ jsx("div", { className: "umami-chart__header", children: /* @__PURE__ */ jsx("span", { className: "umami-dashboard__select umami-skeleton umami-skeleton--select", children: "\xA0" }) }),
|
|
90
|
+
/* @__PURE__ */ jsx("div", { className: "umami-skeleton umami-skeleton--chart" })
|
|
91
|
+
] }),
|
|
92
|
+
[0, 1].map((card) => /* @__PURE__ */ jsxs("div", { className: "umami-card umami-top", children: [
|
|
93
|
+
/* @__PURE__ */ jsx("span", { className: "umami-card__title umami-skeleton umami-skeleton--title", children: "\xA0" }),
|
|
94
|
+
/* @__PURE__ */ jsx("ol", { className: "umami-top__list", children: Array.from({ length: 5 }, (_, row) => /* @__PURE__ */ jsx("li", { className: "umami-top__row", children: /* @__PURE__ */ jsx("span", { className: "umami-top__name umami-skeleton", children: "\xA0" }) }, row)) })
|
|
95
|
+
] }, card))
|
|
96
|
+
] });
|
|
80
97
|
|
|
81
98
|
// src/components/dashboardStyles.ts
|
|
82
99
|
var dashboardStyles = `
|
|
@@ -144,6 +161,34 @@ html[data-theme='light'] .umami-dashboard {
|
|
|
144
161
|
.umami-top__value { color: var(--theme-elevation-800); font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
145
162
|
.umami-empty { color: var(--theme-elevation-500); margin: 0.75rem 0 0; }
|
|
146
163
|
.umami-message { color: var(--theme-elevation-600); padding: 1rem 0; }
|
|
164
|
+
/*
|
|
165
|
+
* Loading placeholders. Each bar keeps the type metrics of the element it
|
|
166
|
+
* stands in for (it carries that element's class too) and just paints over
|
|
167
|
+
* the text, so the skeleton and the loaded dashboard are the same height.
|
|
168
|
+
*/
|
|
169
|
+
.umami-skeleton {
|
|
170
|
+
animation: umami-skeleton-pulse 1.4s ease-in-out infinite;
|
|
171
|
+
background: var(--theme-elevation-100);
|
|
172
|
+
border-radius: 4px;
|
|
173
|
+
color: transparent;
|
|
174
|
+
display: block;
|
|
175
|
+
max-width: 100%;
|
|
176
|
+
user-select: none;
|
|
177
|
+
}
|
|
178
|
+
.umami-skeleton--label { width: 5rem; }
|
|
179
|
+
.umami-skeleton--value { width: 4.5rem; }
|
|
180
|
+
.umami-skeleton--title { width: 7rem; }
|
|
181
|
+
/* Matches the select it replaces: same box, but no border to double up on. */
|
|
182
|
+
.umami-skeleton--select { border-color: transparent; width: 7rem; }
|
|
183
|
+
/* The height ResponsiveContainer gives the chart in TrafficChart. */
|
|
184
|
+
.umami-skeleton--chart { height: 280px; width: 100%; }
|
|
185
|
+
@keyframes umami-skeleton-pulse {
|
|
186
|
+
0%, 100% { opacity: 1; }
|
|
187
|
+
50% { opacity: 0.55; }
|
|
188
|
+
}
|
|
189
|
+
@media (prefers-reduced-motion: reduce) {
|
|
190
|
+
.umami-skeleton { animation: none; }
|
|
191
|
+
}
|
|
147
192
|
/* Responds to the widget's own width (it can be resized in the dashboard). */
|
|
148
193
|
@container (max-width: 900px) {
|
|
149
194
|
.umami-grid { grid-template-columns: repeat(2, 1fr); }
|
|
@@ -153,7 +198,7 @@ html[data-theme='light'] .umami-dashboard {
|
|
|
153
198
|
`;
|
|
154
199
|
|
|
155
200
|
// src/components/StatCard.tsx
|
|
156
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
201
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
157
202
|
var compact = new Intl.NumberFormat(void 0, { notation: "compact", maximumFractionDigits: 1 });
|
|
158
203
|
var full = new Intl.NumberFormat();
|
|
159
204
|
var formatDuration = (seconds) => {
|
|
@@ -166,30 +211,73 @@ var formatDuration = (seconds) => {
|
|
|
166
211
|
}
|
|
167
212
|
return `${minutes}m ${String(secs).padStart(2, "0")}s`;
|
|
168
213
|
};
|
|
169
|
-
var StatCard = ({ format = "number", label, value }) => /* @__PURE__ */
|
|
170
|
-
/* @__PURE__ */
|
|
171
|
-
/* @__PURE__ */
|
|
214
|
+
var StatCard = ({ format = "number", label, value }) => /* @__PURE__ */ jsxs2("div", { className: "umami-card umami-stat", children: [
|
|
215
|
+
/* @__PURE__ */ jsx2("span", { className: "umami-stat__label", children: label }),
|
|
216
|
+
/* @__PURE__ */ jsx2("span", { className: "umami-stat__value", title: format === "number" ? full.format(value) : void 0, children: format === "duration" ? formatDuration(value) : compact.format(value) })
|
|
172
217
|
] });
|
|
173
218
|
|
|
174
219
|
// src/components/TopList.tsx
|
|
175
|
-
import { jsx as
|
|
220
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
176
221
|
var full2 = new Intl.NumberFormat();
|
|
177
|
-
var TopList = ({ emptyLabel, items, title }) => /* @__PURE__ */
|
|
178
|
-
/* @__PURE__ */
|
|
179
|
-
items.length === 0 ? /* @__PURE__ */
|
|
180
|
-
/* @__PURE__ */
|
|
181
|
-
/* @__PURE__ */
|
|
222
|
+
var TopList = ({ emptyLabel, items, title }) => /* @__PURE__ */ jsxs3("div", { className: "umami-card umami-top", children: [
|
|
223
|
+
/* @__PURE__ */ jsx3("span", { className: "umami-card__title", children: title }),
|
|
224
|
+
items.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "umami-empty", children: emptyLabel }) : /* @__PURE__ */ jsx3("ol", { className: "umami-top__list", children: items.map((item) => /* @__PURE__ */ jsxs3("li", { className: "umami-top__row", children: [
|
|
225
|
+
/* @__PURE__ */ jsx3("span", { className: "umami-top__name", title: item.x, children: item.x || "\u2014" }),
|
|
226
|
+
/* @__PURE__ */ jsx3("span", { className: "umami-top__value", children: full2.format(item.y) })
|
|
182
227
|
] }, item.x)) })
|
|
183
228
|
] });
|
|
184
229
|
|
|
185
230
|
// src/components/TrafficChart.tsx
|
|
186
231
|
import { useMemo, useState } from "react";
|
|
187
232
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
233
|
+
|
|
234
|
+
// src/range.ts
|
|
235
|
+
var DAY = 24 * 60 * 60 * 1e3;
|
|
236
|
+
var RANGE_MS = {
|
|
237
|
+
"24h": DAY,
|
|
238
|
+
"7d": 7 * DAY,
|
|
239
|
+
"30d": 30 * DAY,
|
|
240
|
+
"90d": 90 * DAY
|
|
241
|
+
};
|
|
242
|
+
var rangeToWindow = (range, now = Date.now()) => ({
|
|
243
|
+
startAt: now - RANGE_MS[range],
|
|
244
|
+
endAt: now,
|
|
245
|
+
unit: range === "24h" ? "hour" : "day"
|
|
246
|
+
});
|
|
247
|
+
var floorToBucket = (t, unit) => {
|
|
248
|
+
const date = new Date(t);
|
|
249
|
+
if (unit === "hour") {
|
|
250
|
+
date.setMinutes(0, 0, 0);
|
|
251
|
+
} else {
|
|
252
|
+
date.setHours(0, 0, 0, 0);
|
|
253
|
+
}
|
|
254
|
+
return date.getTime();
|
|
255
|
+
};
|
|
256
|
+
var bucketGrid = (range, now = Date.now()) => {
|
|
257
|
+
const { endAt, startAt, unit } = rangeToWindow(range, now);
|
|
258
|
+
const last = floorToBucket(endAt, unit);
|
|
259
|
+
const cursor = new Date(floorToBucket(startAt, unit));
|
|
260
|
+
const buckets = [];
|
|
261
|
+
while (cursor.getTime() <= last) {
|
|
262
|
+
buckets.push(cursor.getTime());
|
|
263
|
+
if (unit === "hour") {
|
|
264
|
+
cursor.setHours(cursor.getHours() + 1);
|
|
265
|
+
} else {
|
|
266
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return buckets;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// src/components/TrafficChart.tsx
|
|
273
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
274
|
+
var mergeSeries = (series, range) => {
|
|
275
|
+
const unit = rangeToWindow(range).unit;
|
|
276
|
+
const byTime = new Map(
|
|
277
|
+
bucketGrid(range).map((t) => [t, { t, views: 0, visitors: 0 }])
|
|
278
|
+
);
|
|
191
279
|
const ensure = (x) => {
|
|
192
|
-
const t = new Date(x).getTime();
|
|
280
|
+
const t = floorToBucket(new Date(x).getTime(), unit);
|
|
193
281
|
let row = byTime.get(t);
|
|
194
282
|
if (!row) {
|
|
195
283
|
row = { t, views: 0, visitors: 0 };
|
|
@@ -209,7 +297,7 @@ var TrafficChart = ({
|
|
|
209
297
|
visitorsLabel
|
|
210
298
|
}) => {
|
|
211
299
|
const [metric, setMetric] = useState("views");
|
|
212
|
-
const data = useMemo(() => mergeSeries(series), [series]);
|
|
300
|
+
const data = useMemo(() => mergeSeries(series, range), [series, range]);
|
|
213
301
|
const metricLabels = {
|
|
214
302
|
views: viewsLabel,
|
|
215
303
|
visitors: visitorsLabel
|
|
@@ -218,20 +306,29 @@ var TrafficChart = ({
|
|
|
218
306
|
const date = new Date(t);
|
|
219
307
|
return range === "24h" ? date.toLocaleTimeString(void 0, { hour: "2-digit" }) : date.toLocaleDateString(void 0, { day: "numeric", month: "short" });
|
|
220
308
|
};
|
|
221
|
-
|
|
222
|
-
|
|
309
|
+
const formatTooltipLabel = (t) => {
|
|
310
|
+
const date = new Date(t);
|
|
311
|
+
return range === "24h" ? date.toLocaleString() : date.toLocaleDateString(void 0, {
|
|
312
|
+
day: "numeric",
|
|
313
|
+
month: "long",
|
|
314
|
+
weekday: "short",
|
|
315
|
+
year: "numeric"
|
|
316
|
+
});
|
|
317
|
+
};
|
|
318
|
+
return /* @__PURE__ */ jsxs4("div", { className: "umami-card umami-chart", children: [
|
|
319
|
+
/* @__PURE__ */ jsx4("div", { className: "umami-chart__header", children: /* @__PURE__ */ jsx4(
|
|
223
320
|
"select",
|
|
224
321
|
{
|
|
225
322
|
"aria-label": metricLabel,
|
|
226
323
|
className: "umami-dashboard__select",
|
|
227
324
|
onChange: (event) => setMetric(event.target.value),
|
|
228
325
|
value: metric,
|
|
229
|
-
children: ["views", "visitors"].map((value) => /* @__PURE__ */
|
|
326
|
+
children: ["views", "visitors"].map((value) => /* @__PURE__ */ jsx4("option", { value, children: metricLabels[value] }, value))
|
|
230
327
|
}
|
|
231
328
|
) }),
|
|
232
|
-
/* @__PURE__ */
|
|
233
|
-
/* @__PURE__ */
|
|
234
|
-
/* @__PURE__ */
|
|
329
|
+
/* @__PURE__ */ jsx4(ResponsiveContainer, { height: 280, width: "100%", children: /* @__PURE__ */ jsxs4(BarChart, { data, margin: { top: 8, right: 12, bottom: 0, left: -8 }, children: [
|
|
330
|
+
/* @__PURE__ */ jsx4(CartesianGrid, { stroke: "var(--umami-grid)", vertical: false }),
|
|
331
|
+
/* @__PURE__ */ jsx4(
|
|
235
332
|
XAxis,
|
|
236
333
|
{
|
|
237
334
|
dataKey: "t",
|
|
@@ -241,7 +338,7 @@ var TrafficChart = ({
|
|
|
241
338
|
tickLine: false
|
|
242
339
|
}
|
|
243
340
|
),
|
|
244
|
-
/* @__PURE__ */
|
|
341
|
+
/* @__PURE__ */ jsx4(
|
|
245
342
|
YAxis,
|
|
246
343
|
{
|
|
247
344
|
allowDecimals: false,
|
|
@@ -251,7 +348,7 @@ var TrafficChart = ({
|
|
|
251
348
|
width: 44
|
|
252
349
|
}
|
|
253
350
|
),
|
|
254
|
-
/* @__PURE__ */
|
|
351
|
+
/* @__PURE__ */ jsx4(
|
|
255
352
|
Tooltip,
|
|
256
353
|
{
|
|
257
354
|
contentStyle: {
|
|
@@ -261,10 +358,10 @@ var TrafficChart = ({
|
|
|
261
358
|
color: "var(--theme-text)"
|
|
262
359
|
},
|
|
263
360
|
cursor: { fill: "var(--umami-grid)", opacity: 0.5 },
|
|
264
|
-
labelFormatter: (t) =>
|
|
361
|
+
labelFormatter: (t) => formatTooltipLabel(Number(t))
|
|
265
362
|
}
|
|
266
363
|
),
|
|
267
|
-
/* @__PURE__ */
|
|
364
|
+
/* @__PURE__ */ jsx4(
|
|
268
365
|
Bar,
|
|
269
366
|
{
|
|
270
367
|
dataKey: metric,
|
|
@@ -279,7 +376,7 @@ var TrafficChart = ({
|
|
|
279
376
|
};
|
|
280
377
|
|
|
281
378
|
// src/components/UmamiDashboard.tsx
|
|
282
|
-
import { jsx as
|
|
379
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
283
380
|
var avgDuration = (stats) => stats.visits > 0 ? stats.totaltime / stats.visits : 0;
|
|
284
381
|
var STAT_DEFS = {
|
|
285
382
|
visitors: { period: "current", value: (s) => s.visitors },
|
|
@@ -312,6 +409,7 @@ var UmamiDashboard = ({
|
|
|
312
409
|
}
|
|
313
410
|
};
|
|
314
411
|
}, [i18n.language]);
|
|
412
|
+
const visibleStats = useMemo2(() => stats.filter((id) => id in STAT_DEFS), [stats]);
|
|
315
413
|
const [range, setRange] = useState2(defaultRange);
|
|
316
414
|
const [report, setReport] = useState2();
|
|
317
415
|
const [status, setStatus] = useState2("loading");
|
|
@@ -340,25 +438,25 @@ var UmamiDashboard = ({
|
|
|
340
438
|
void load();
|
|
341
439
|
return () => controller.abort();
|
|
342
440
|
}, [config.serverURL, config.routes.api, range]);
|
|
343
|
-
return /* @__PURE__ */
|
|
344
|
-
/* @__PURE__ */
|
|
345
|
-
/* @__PURE__ */
|
|
346
|
-
/* @__PURE__ */
|
|
347
|
-
showRangeSelector && /* @__PURE__ */
|
|
441
|
+
return /* @__PURE__ */ jsxs5("div", { className: "umami-dashboard", children: [
|
|
442
|
+
/* @__PURE__ */ jsx5("style", { children: dashboardStyles }),
|
|
443
|
+
/* @__PURE__ */ jsxs5("div", { className: "umami-dashboard__header", children: [
|
|
444
|
+
/* @__PURE__ */ jsx5("h2", { className: "umami-dashboard__title", children: t.title }),
|
|
445
|
+
showRangeSelector && /* @__PURE__ */ jsx5(
|
|
348
446
|
"select",
|
|
349
447
|
{
|
|
350
448
|
"aria-label": t.timeRange,
|
|
351
449
|
className: "umami-dashboard__select",
|
|
352
450
|
onChange: (event) => setRange(event.target.value),
|
|
353
451
|
value: range,
|
|
354
|
-
children: UMAMI_RANGES.map((value) => /* @__PURE__ */
|
|
452
|
+
children: UMAMI_RANGES.map((value) => /* @__PURE__ */ jsx5("option", { value, children: t.ranges[value] }, value))
|
|
355
453
|
}
|
|
356
454
|
)
|
|
357
455
|
] }),
|
|
358
|
-
status === "error" ? /* @__PURE__ */
|
|
359
|
-
|
|
456
|
+
status === "error" ? /* @__PURE__ */ jsx5("p", { className: "umami-message", children: t.messages.error }) : status === "loading" && !report ? /* @__PURE__ */ jsx5(DashboardSkeleton, { label: t.messages.loading, statCount: visibleStats.length }) : report ? /* @__PURE__ */ jsxs5("div", { className: "umami-grid", style: { opacity: status === "loading" ? 0.6 : 1 }, children: [
|
|
457
|
+
visibleStats.map((id) => {
|
|
360
458
|
const def = STAT_DEFS[id];
|
|
361
|
-
return /* @__PURE__ */
|
|
459
|
+
return /* @__PURE__ */ jsx5(
|
|
362
460
|
StatCard,
|
|
363
461
|
{
|
|
364
462
|
format: def.format,
|
|
@@ -368,7 +466,7 @@ var UmamiDashboard = ({
|
|
|
368
466
|
id
|
|
369
467
|
);
|
|
370
468
|
}),
|
|
371
|
-
/* @__PURE__ */
|
|
469
|
+
/* @__PURE__ */ jsx5(
|
|
372
470
|
TrafficChart,
|
|
373
471
|
{
|
|
374
472
|
metricLabel: t.chartMetric,
|
|
@@ -378,8 +476,8 @@ var UmamiDashboard = ({
|
|
|
378
476
|
visitorsLabel: t.stats.visitors
|
|
379
477
|
}
|
|
380
478
|
),
|
|
381
|
-
/* @__PURE__ */
|
|
382
|
-
/* @__PURE__ */
|
|
479
|
+
/* @__PURE__ */ jsx5(TopList, { emptyLabel: t.messages.noPages, items: report.topPages, title: t.topPages }),
|
|
480
|
+
/* @__PURE__ */ jsx5(
|
|
383
481
|
TopList,
|
|
384
482
|
{
|
|
385
483
|
emptyLabel: t.messages.noCountries,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/UmamiDashboard.tsx","../../src/translations/en.ts","../../src/translations/fr.ts","../../src/types.ts","../../src/components/dashboardStyles.ts","../../src/components/StatCard.tsx","../../src/components/TopList.tsx","../../src/components/TrafficChart.tsx"],"sourcesContent":["'use client'\n\nimport { useConfig, useTranslation } from '@payloadcms/ui'\nimport React, { useEffect, useMemo, useState } from 'react'\n\nimport type {\n UmamiDashboardProps,\n UmamiRange,\n UmamiReport,\n UmamiStatId,\n UmamiStats,\n} from '../types.js'\n\nimport { en } from '../translations/en.js'\nimport { fr } from '../translations/fr.js'\nimport { DEFAULT_STATS, UMAMI_RANGES } from '../types.js'\nimport { dashboardStyles } from './dashboardStyles.js'\nimport { StatCard } from './StatCard.js'\nimport { TopList } from './TopList.js'\nimport { TrafficChart } from './TrafficChart.js'\n\n/** Average visit duration in seconds. */\nconst avgDuration = (stats: UmamiStats): number =>\n stats.visits > 0 ? stats.totaltime / stats.visits : 0\n\ntype StatDef = {\n format?: 'duration' | 'number'\n /** Which period's stats the card reads from. */\n period: 'current' | 'prev'\n value: (stats: UmamiStats) => number\n}\n\n// Labels come from translations (t.umami.stats[id]).\nconst STAT_DEFS: Record<UmamiStatId, StatDef> = {\n visitors: { period: 'current', value: (s) => s.visitors },\n visitorsPrev: { period: 'prev', value: (s) => s.visitors },\n views: { period: 'current', value: (s) => s.pageviews },\n viewsPrev: { period: 'prev', value: (s) => s.pageviews },\n visits: { period: 'current', value: (s) => s.visits },\n visitsPrev: { period: 'prev', value: (s) => s.visits },\n bounces: { period: 'current', value: (s) => s.bounces },\n bouncesPrev: { period: 'prev', value: (s) => s.bounces },\n duration: { format: 'duration', period: 'current', value: avgDuration },\n durationPrev: { format: 'duration', period: 'prev', value: avgDuration },\n}\n\n/**\n * Dashboard section that presents five Umami widgets — visits, views, top 5\n * pages, top 5 countries, and a visitors/views time chart — over a shared,\n * user-selectable time range. All widgets are driven by a single fetch to the\n * `/plugin-umami/report` proxy endpoint, so Umami credentials stay server-side.\n */\nexport const UmamiDashboard = ({\n defaultRange = '7d',\n showRangeSelector = true,\n stats = DEFAULT_STATS,\n}: UmamiDashboardProps) => {\n const { config } = useConfig()\n const { i18n } = useTranslation()\n const t = (i18n.language === 'fr' ? fr : en).umami\n\n // Umami reports countries as ISO 3166-1 alpha-2 codes; Intl.DisplayNames\n // localizes them in the admin language with no bundled data.\n const countryName = useMemo(() => {\n const regionNames = new Intl.DisplayNames([i18n.language, 'en'], { type: 'region' })\n return (code: string): string => {\n if (!code) return code\n try {\n return regionNames.of(code.toUpperCase()) ?? code\n } catch {\n // Invalid code (e.g. Umami's \"Unknown\" bucket) — show it as-is.\n return code\n }\n }\n }, [i18n.language])\n const [range, setRange] = useState<UmamiRange>(defaultRange)\n const [report, setReport] = useState<UmamiReport>()\n const [status, setStatus] = useState<'error' | 'loading' | 'ready'>('loading')\n\n useEffect(() => {\n const controller = new AbortController()\n\n const load = async () => {\n setStatus('loading')\n try {\n const response = await fetch(\n `${config.serverURL}${config.routes.api}/plugin-umami/report?range=${range}`,\n { credentials: 'include', signal: controller.signal },\n )\n if (!response.ok) {\n setStatus('error')\n return\n }\n setReport((await response.json()) as UmamiReport)\n setStatus('ready')\n } catch (error) {\n if (!controller.signal.aborted) {\n console.error('Failed to load Umami analytics', error)\n setStatus('error')\n }\n }\n }\n\n void load()\n\n return () => controller.abort()\n }, [config.serverURL, config.routes.api, range])\n\n return (\n <div className=\"umami-dashboard\">\n <style>{dashboardStyles}</style>\n <div className=\"umami-dashboard__header\">\n <h2 className=\"umami-dashboard__title\">{t.title}</h2>\n {showRangeSelector && (\n <select\n aria-label={t.timeRange}\n className=\"umami-dashboard__select\"\n onChange={(event) => setRange(event.target.value as UmamiRange)}\n value={range}\n >\n {UMAMI_RANGES.map((value) => (\n <option key={value} value={value}>\n {t.ranges[value]}\n </option>\n ))}\n </select>\n )}\n </div>\n\n {status === 'error' ? (\n <p className=\"umami-message\">{t.messages.error}</p>\n ) : status === 'loading' && !report ? (\n <p className=\"umami-message\">{t.messages.loading}</p>\n ) : report ? (\n <div className=\"umami-grid\" style={{ opacity: status === 'loading' ? 0.6 : 1 }}>\n {stats\n .filter((id) => id in STAT_DEFS)\n .map((id) => {\n const def = STAT_DEFS[id]\n return (\n <StatCard\n format={def.format}\n key={id}\n label={t.stats[id]}\n value={def.value(def.period === 'prev' ? report.prevStats : report.stats)}\n />\n )\n })}\n <TrafficChart\n metricLabel={t.chartMetric}\n range={report.range}\n series={report.series}\n viewsLabel={t.stats.views}\n visitorsLabel={t.stats.visitors}\n />\n <TopList emptyLabel={t.messages.noPages} items={report.topPages} title={t.topPages} />\n <TopList\n emptyLabel={t.messages.noCountries}\n items={report.topCountries.map((point) => ({ ...point, x: countryName(point.x) }))}\n title={t.topCountries}\n />\n </div>\n ) : null}\n </div>\n )\n}\n","export const en = {\n umami: {\n title: 'Analytics',\n timeRange: 'Time range',\n chartMetric: 'Chart stat',\n ranges: {\n '24h': 'Last 24 hours',\n '7d': 'Last 7 days',\n '30d': 'Last 30 days',\n '90d': 'Last 90 days',\n },\n stats: {\n bounces: 'Bounces',\n bouncesPrev: 'Bounces (last period)',\n duration: 'Avg. visit duration',\n durationPrev: 'Avg. visit duration (last period)',\n views: 'Views',\n viewsPrev: 'Views (last period)',\n visitors: 'Visitors',\n visitorsPrev: 'Visitors (last period)',\n visits: 'Visits',\n visitsPrev: 'Visits (last period)',\n },\n topPages: 'Top pages',\n topCountries: 'Top countries',\n messages: {\n error: 'Could not load analytics. Check the Umami configuration.',\n loading: 'Loading analytics…',\n noCountries: 'No countries in this range.',\n noPages: 'No pages in this range.',\n },\n },\n}\n","import type { Translation } from './index.js'\n\nexport const fr: Translation = {\n umami: {\n title: 'Statistiques',\n timeRange: 'Période',\n chartMetric: 'Métrique du graphique',\n ranges: {\n '24h': 'Dernières 24 heures',\n '7d': '7 derniers jours',\n '30d': '30 derniers jours',\n '90d': '90 derniers jours',\n },\n stats: {\n bounces: 'Rebonds',\n bouncesPrev: 'Rebonds (période précédente)',\n duration: 'Durée moyenne de visite',\n durationPrev: 'Durée moyenne de visite (période précédente)',\n views: 'Pages vues',\n viewsPrev: 'Pages vues (période précédente)',\n visitors: 'Visiteurs',\n visitorsPrev: 'Visiteurs (période précédente)',\n visits: 'Visites',\n visitsPrev: 'Visites (période précédente)',\n },\n topPages: 'Pages les plus vues',\n topCountries: 'Principaux pays',\n messages: {\n error: 'Impossible de charger les statistiques. Vérifiez la configuration Umami.',\n loading: 'Chargement des statistiques…',\n noCountries: 'Aucun pays sur cette période.',\n noPages: 'Aucune page sur cette période.',\n },\n },\n}\n","import type { Access } from 'payload'\n\nexport type UmamiAccess = {\n /**\n * Who can see the analytics. Evaluated when rendering the dashboard widget\n * (denied users get nothing) and again in the report endpoint.\n * Defaults to any authenticated user.\n */\n read?: Access\n}\n\nexport type UmamiRange = '24h' | '7d' | '30d' | '90d'\n\nexport const UMAMI_RANGES: UmamiRange[] = ['24h', '7d', '30d', '90d']\n\n/** A single `{ x, y }` point as returned by Umami metrics/pageviews. */\nexport type UmamiPoint = {\n x: string\n y: number\n}\n\n/** Response of `GET /websites/:id/stats`. */\nexport type UmamiStats = {\n pageviews: number\n visitors: number\n visits: number\n bounces: number\n totaltime: number\n}\n\n/** Response of `GET /websites/:id/pageviews`. */\nexport type UmamiSeries = {\n /** Views over time. */\n pageviews: UmamiPoint[]\n /** Visitor sessions over time. */\n sessions: UmamiPoint[]\n}\n\n/** The combined payload returned by the `/plugin-umami/report` endpoint. */\nexport type UmamiReport = {\n range: UmamiRange\n stats: UmamiStats\n /** Same stats over the period immediately before the selected range. */\n prevStats: UmamiStats\n topPages: UmamiPoint[]\n topCountries: UmamiPoint[]\n series: UmamiSeries\n}\n\n/**\n * A stat card shown in the dashboard. `duration` is the average visit\n * duration (`totaltime / visits`). The `Prev` variants show the same metric\n * over the period immediately before the selected range.\n */\nexport type UmamiStatId =\n | 'bounces'\n | 'bouncesPrev'\n | 'duration'\n | 'durationPrev'\n | 'views'\n | 'viewsPrev'\n | 'visitors'\n | 'visitorsPrev'\n | 'visits'\n | 'visitsPrev'\n\nexport const DEFAULT_STATS: UmamiStatId[] = ['visitors', 'views', 'visitorsPrev', 'viewsPrev']\n\n/** Props passed from the plugin to the dashboard client component. */\nexport type UmamiDashboardProps = {\n defaultRange?: UmamiRange\n showRangeSelector?: boolean\n stats?: UmamiStatId[]\n}\n","/**\n * Scoped styles for the Umami dashboard, injected via a `<style>` tag. Series\n * colors are categorical slots 1 (blue) & 2 (aqua) from the validated data-viz\n * palette, stepped per surface; chrome uses Payload's own admin theme variables\n * so the widgets match the panel in both light and dark themes.\n */\nexport const dashboardStyles = `\n.umami-dashboard {\n --umami-series-views: #2a78d6;\n --umami-series-visitors: #1baf7a;\n --umami-grid: #e1e0d9;\n --umami-axis: #c3c2b7;\n --umami-muted: #898781;\n container-type: inline-size;\n margin-bottom: 2rem;\n}\n@media (prefers-color-scheme: dark) {\n .umami-dashboard {\n --umami-series-views: #3987e5;\n --umami-series-visitors: #199e70;\n --umami-grid: #2c2c2a;\n --umami-axis: #383835;\n }\n}\nhtml[data-theme='dark'] .umami-dashboard {\n --umami-series-views: #3987e5;\n --umami-series-visitors: #199e70;\n --umami-grid: #2c2c2a;\n --umami-axis: #383835;\n}\nhtml[data-theme='light'] .umami-dashboard {\n --umami-series-views: #2a78d6;\n --umami-series-visitors: #1baf7a;\n --umami-grid: #e1e0d9;\n --umami-axis: #c3c2b7;\n}\n.umami-dashboard__header {\n align-items: center;\n display: flex;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n// .umami-dashboard__title { font-size: 1.15rem; font-weight: 600; margin: 0; }\n.umami-dashboard__select {\n background: var(--theme-elevation-50);\n border: 1px solid var(--theme-elevation-150);\n border-radius: 4px;\n color: var(--theme-text);\n padding: 0.4rem 0.6rem;\n}\n.umami-grid { display: grid; gap: 1rem; grid-template-columns: repeat(4, 1fr); }\n.umami-card {\n background: var(--theme-elevation-50);\n border: 1px solid var(--theme-elevation-100);\n border-radius: 6px;\n padding: 1rem;\n}\n.umami-card__title { color: var(--theme-elevation-800); font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }\n.umami-stat { display: flex; flex-direction: column; gap: 0.35rem; grid-column: span 1; }\n.umami-stat__label { color: var(--theme-elevation-600); font-size: 0.85rem; }\n.umami-stat__value { font-size: 2rem; font-weight: 700; line-height: 1; }\n.umami-chart { grid-column: span 4; }\n.umami-chart__header { display: flex; justify-content: flex-end; margin-bottom: 0.75rem; }\n.umami-top { grid-column: span 2; }\n.umami-top__list { list-style: none; margin: 0.75rem 0 0; padding: 0; }\n.umami-top__row { align-items: baseline; display: flex; gap: 0.75rem; justify-content: space-between; padding: 0.3rem 0; }\n.umami-top__name { color: var(--theme-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.umami-top__value { color: var(--theme-elevation-800); font-variant-numeric: tabular-nums; font-weight: 600; }\n.umami-empty { color: var(--theme-elevation-500); margin: 0.75rem 0 0; }\n.umami-message { color: var(--theme-elevation-600); padding: 1rem 0; }\n/* Responds to the widget's own width (it can be resized in the dashboard). */\n@container (max-width: 900px) {\n .umami-grid { grid-template-columns: repeat(2, 1fr); }\n .umami-stat, .umami-top { grid-column: span 1; }\n .umami-chart { grid-column: span 2; }\n}\n`\n","'use client'\n\nimport React from 'react'\n\nexport type StatCardProps = {\n label: string\n value: number\n /** How the value is rendered: a count or a duration in seconds. @default 'number' */\n format?: 'duration' | 'number'\n}\n\nconst compact = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })\nconst full = new Intl.NumberFormat()\n\n/** Formats seconds as `3m 24s` (or `1h 02m` past an hour). */\nconst formatDuration = (seconds: number): string => {\n const total = Math.round(seconds)\n const hours = Math.floor(total / 3600)\n const minutes = Math.floor((total % 3600) / 60)\n const secs = total % 60\n if (hours > 0) {\n return `${hours}h ${String(minutes).padStart(2, '0')}m`\n }\n return `${minutes}m ${String(secs).padStart(2, '0')}s`\n}\n\n/** A single headline figure (visitors, views, average visit duration, ...). */\nexport const StatCard = ({ format = 'number', label, value }: StatCardProps) => (\n <div className=\"umami-card umami-stat\">\n <span className=\"umami-stat__label\">{label}</span>\n <span className=\"umami-stat__value\" title={format === 'number' ? full.format(value) : undefined}>\n {format === 'duration' ? formatDuration(value) : compact.format(value)}\n </span>\n </div>\n)\n","'use client'\n\nimport React from 'react'\n\nimport type { UmamiPoint } from '../types.js'\n\nexport type TopListProps = {\n title: string\n items: UmamiPoint[]\n /** Message shown when there is no data in the range. */\n emptyLabel: string\n}\n\nconst full = new Intl.NumberFormat()\n\n/** A ranked \"top 5\" list (used for Top Pages and Top Countries). */\nexport const TopList = ({ emptyLabel, items, title }: TopListProps) => (\n <div className=\"umami-card umami-top\">\n <span className=\"umami-card__title\">{title}</span>\n {items.length === 0 ? (\n <p className=\"umami-empty\">{emptyLabel}</p>\n ) : (\n <ol className=\"umami-top__list\">\n {items.map((item) => (\n <li className=\"umami-top__row\" key={item.x}>\n <span className=\"umami-top__name\" title={item.x}>\n {item.x || '—'}\n </span>\n <span className=\"umami-top__value\">{full.format(item.y)}</span>\n </li>\n ))}\n </ol>\n )}\n </div>\n)\n","'use client'\n\nimport React, { useMemo, useState } from 'react'\nimport { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'\n\nimport type { UmamiRange, UmamiSeries } from '../types.js'\n\nexport type TrafficChartProps = {\n series: UmamiSeries\n range: UmamiRange\n /** Accessible label for the stat dropdown. */\n metricLabel: string\n viewsLabel: string\n visitorsLabel: string\n}\n\n/**\n * The stats Umami exposes as a time series. Visits over time is not\n * available: the `/pageviews` endpoint only returns pageviews and sessions\n * buckets (Umami's own dashboard chart has the same two).\n */\ntype ChartMetric = 'views' | 'visitors'\n\ntype Row = { t: number; views: number; visitors: number }\n\nconst mergeSeries = (series: UmamiSeries): Row[] => {\n const byTime = new Map<number, Row>()\n const ensure = (x: string): Row => {\n const t = new Date(x).getTime()\n let row = byTime.get(t)\n if (!row) {\n row = { t, views: 0, visitors: 0 }\n byTime.set(t, row)\n }\n return row\n }\n for (const point of series.pageviews) ensure(point.x).views = point.y\n for (const point of series.sessions) ensure(point.x).visitors = point.y\n return [...byTime.values()].sort((a, b) => a.t - b.t)\n}\n\n/**\n * Traffic over time as a bar chart showing one stat at a time — views (the\n * default) or visitors, picked from an in-card dropdown. Single series, so no\n * legend; the color follows the stat (views = blue, visitors = aqua, the\n * series CSS vars defined by the dashboard). Chrome uses Payload's admin\n * theme variables.\n */\nexport const TrafficChart = ({\n metricLabel,\n range,\n series,\n viewsLabel,\n visitorsLabel,\n}: TrafficChartProps) => {\n const [metric, setMetric] = useState<ChartMetric>('views')\n const data = useMemo(() => mergeSeries(series), [series])\n\n const metricLabels: Record<ChartMetric, string> = {\n views: viewsLabel,\n visitors: visitorsLabel,\n }\n\n const formatTick = (t: number) => {\n const date = new Date(t)\n return range === '24h'\n ? date.toLocaleTimeString(undefined, { hour: '2-digit' })\n : date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })\n }\n\n return (\n <div className=\"umami-card umami-chart\">\n <div className=\"umami-chart__header\">\n <select\n aria-label={metricLabel}\n className=\"umami-dashboard__select\"\n onChange={(event) => setMetric(event.target.value as ChartMetric)}\n value={metric}\n >\n {(['views', 'visitors'] as const).map((value) => (\n <option key={value} value={value}>\n {metricLabels[value]}\n </option>\n ))}\n </select>\n </div>\n <ResponsiveContainer height={280} width=\"100%\">\n <BarChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>\n <CartesianGrid stroke=\"var(--umami-grid)\" vertical={false} />\n <XAxis\n dataKey=\"t\"\n stroke=\"var(--umami-axis)\"\n tick={{ fill: 'var(--umami-muted)', fontSize: 12 }}\n tickFormatter={formatTick}\n tickLine={false}\n />\n <YAxis\n allowDecimals={false}\n stroke=\"var(--umami-axis)\"\n tick={{ fill: 'var(--umami-muted)', fontSize: 12 }}\n tickLine={false}\n width={44}\n />\n <Tooltip\n contentStyle={{\n background: 'var(--theme-elevation-50)',\n border: '1px solid var(--theme-elevation-150)',\n borderRadius: 4,\n color: 'var(--theme-text)',\n }}\n cursor={{ fill: 'var(--umami-grid)', opacity: 0.5 }}\n labelFormatter={(t) => new Date(Number(t)).toLocaleString()}\n />\n <Bar\n dataKey={metric}\n fill={metric === 'views' ? 'var(--umami-series-views)' : 'var(--umami-series-visitors)'}\n maxBarSize={40}\n name={metricLabels[metric]}\n radius={[4, 4, 0, 0]}\n />\n </BarChart>\n </ResponsiveContainer>\n </div>\n )\n}\n"],"mappings":"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","names":["useMemo","useState","jsx","jsxs","full","jsx","jsxs","jsx","jsxs","useMemo","useState"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/UmamiDashboard.tsx","../../src/translations/en.ts","../../src/translations/fr.ts","../../src/types.ts","../../src/components/DashboardSkeleton.tsx","../../src/components/dashboardStyles.ts","../../src/components/StatCard.tsx","../../src/components/TopList.tsx","../../src/components/TrafficChart.tsx","../../src/range.ts"],"sourcesContent":["'use client'\n\nimport { useConfig, useTranslation } from '@payloadcms/ui'\nimport React, { useEffect, useMemo, useState } from 'react'\n\nimport type {\n UmamiDashboardProps,\n UmamiRange,\n UmamiReport,\n UmamiStatId,\n UmamiStats,\n} from '../types.js'\n\nimport { en } from '../translations/en.js'\nimport { fr } from '../translations/fr.js'\nimport { DEFAULT_STATS, UMAMI_RANGES } from '../types.js'\nimport { DashboardSkeleton } from './DashboardSkeleton.js'\nimport { dashboardStyles } from './dashboardStyles.js'\nimport { StatCard } from './StatCard.js'\nimport { TopList } from './TopList.js'\nimport { TrafficChart } from './TrafficChart.js'\n\n/** Average visit duration in seconds. */\nconst avgDuration = (stats: UmamiStats): number =>\n stats.visits > 0 ? stats.totaltime / stats.visits : 0\n\ntype StatDef = {\n format?: 'duration' | 'number'\n /** Which period's stats the card reads from. */\n period: 'current' | 'prev'\n value: (stats: UmamiStats) => number\n}\n\n// Labels come from translations (t.umami.stats[id]).\nconst STAT_DEFS: Record<UmamiStatId, StatDef> = {\n visitors: { period: 'current', value: (s) => s.visitors },\n visitorsPrev: { period: 'prev', value: (s) => s.visitors },\n views: { period: 'current', value: (s) => s.pageviews },\n viewsPrev: { period: 'prev', value: (s) => s.pageviews },\n visits: { period: 'current', value: (s) => s.visits },\n visitsPrev: { period: 'prev', value: (s) => s.visits },\n bounces: { period: 'current', value: (s) => s.bounces },\n bouncesPrev: { period: 'prev', value: (s) => s.bounces },\n duration: { format: 'duration', period: 'current', value: avgDuration },\n durationPrev: { format: 'duration', period: 'prev', value: avgDuration },\n}\n\n/**\n * Dashboard section that presents five Umami widgets — visits, views, top 5\n * pages, top 5 countries, and a visitors/views time chart — over a shared,\n * user-selectable time range. All widgets are driven by a single fetch to the\n * `/plugin-umami/report` proxy endpoint, so Umami credentials stay server-side.\n */\nexport const UmamiDashboard = ({\n defaultRange = '7d',\n showRangeSelector = true,\n stats = DEFAULT_STATS,\n}: UmamiDashboardProps) => {\n const { config } = useConfig()\n const { i18n } = useTranslation()\n const t = (i18n.language === 'fr' ? fr : en).umami\n\n // Umami reports countries as ISO 3166-1 alpha-2 codes; Intl.DisplayNames\n // localizes them in the admin language with no bundled data.\n const countryName = useMemo(() => {\n const regionNames = new Intl.DisplayNames([i18n.language, 'en'], { type: 'region' })\n return (code: string): string => {\n if (!code) return code\n try {\n return regionNames.of(code.toUpperCase()) ?? code\n } catch {\n // Invalid code (e.g. Umami's \"Unknown\" bucket) — show it as-is.\n return code\n }\n }\n }, [i18n.language])\n // Guards against unknown ids in the plugin config, and gives the loading\n // skeleton the same card count the loaded grid will have.\n const visibleStats = useMemo(() => stats.filter((id) => id in STAT_DEFS), [stats])\n const [range, setRange] = useState<UmamiRange>(defaultRange)\n const [report, setReport] = useState<UmamiReport>()\n const [status, setStatus] = useState<'error' | 'loading' | 'ready'>('loading')\n\n useEffect(() => {\n const controller = new AbortController()\n\n const load = async () => {\n setStatus('loading')\n try {\n const response = await fetch(\n `${config.serverURL}${config.routes.api}/plugin-umami/report?range=${range}`,\n { credentials: 'include', signal: controller.signal },\n )\n if (!response.ok) {\n setStatus('error')\n return\n }\n setReport((await response.json()) as UmamiReport)\n setStatus('ready')\n } catch (error) {\n if (!controller.signal.aborted) {\n console.error('Failed to load Umami analytics', error)\n setStatus('error')\n }\n }\n }\n\n void load()\n\n return () => controller.abort()\n }, [config.serverURL, config.routes.api, range])\n\n return (\n <div className=\"umami-dashboard\">\n <style>{dashboardStyles}</style>\n <div className=\"umami-dashboard__header\">\n <h2 className=\"umami-dashboard__title\">{t.title}</h2>\n {showRangeSelector && (\n <select\n aria-label={t.timeRange}\n className=\"umami-dashboard__select\"\n onChange={(event) => setRange(event.target.value as UmamiRange)}\n value={range}\n >\n {UMAMI_RANGES.map((value) => (\n <option key={value} value={value}>\n {t.ranges[value]}\n </option>\n ))}\n </select>\n )}\n </div>\n\n {status === 'error' ? (\n <p className=\"umami-message\">{t.messages.error}</p>\n ) : status === 'loading' && !report ? (\n <DashboardSkeleton label={t.messages.loading} statCount={visibleStats.length} />\n ) : report ? (\n <div className=\"umami-grid\" style={{ opacity: status === 'loading' ? 0.6 : 1 }}>\n {visibleStats.map((id) => {\n const def = STAT_DEFS[id]\n return (\n <StatCard\n format={def.format}\n key={id}\n label={t.stats[id]}\n value={def.value(def.period === 'prev' ? report.prevStats : report.stats)}\n />\n )\n })}\n <TrafficChart\n metricLabel={t.chartMetric}\n range={report.range}\n series={report.series}\n viewsLabel={t.stats.views}\n visitorsLabel={t.stats.visitors}\n />\n <TopList emptyLabel={t.messages.noPages} items={report.topPages} title={t.topPages} />\n <TopList\n emptyLabel={t.messages.noCountries}\n items={report.topCountries.map((point) => ({ ...point, x: countryName(point.x) }))}\n title={t.topCountries}\n />\n </div>\n ) : null}\n </div>\n )\n}\n","export const en = {\n umami: {\n title: 'Analytics',\n timeRange: 'Time range',\n chartMetric: 'Chart stat',\n ranges: {\n '24h': 'Last 24 hours',\n '7d': 'Last 7 days',\n '30d': 'Last 30 days',\n '90d': 'Last 90 days',\n },\n stats: {\n bounces: 'Bounces',\n bouncesPrev: 'Bounces (last period)',\n duration: 'Avg. visit duration',\n durationPrev: 'Avg. visit duration (last period)',\n views: 'Views',\n viewsPrev: 'Views (last period)',\n visitors: 'Visitors',\n visitorsPrev: 'Visitors (last period)',\n visits: 'Visits',\n visitsPrev: 'Visits (last period)',\n },\n topPages: 'Top pages',\n topCountries: 'Top countries',\n messages: {\n error: 'Could not load analytics. Check the Umami configuration.',\n loading: 'Loading analytics…',\n noCountries: 'No countries in this range.',\n noPages: 'No pages in this range.',\n },\n },\n}\n","import type { Translation } from './index.js'\n\nexport const fr: Translation = {\n umami: {\n title: 'Statistiques',\n timeRange: 'Période',\n chartMetric: 'Métrique du graphique',\n ranges: {\n '24h': 'Dernières 24 heures',\n '7d': '7 derniers jours',\n '30d': '30 derniers jours',\n '90d': '90 derniers jours',\n },\n stats: {\n bounces: 'Rebonds',\n bouncesPrev: 'Rebonds (période précédente)',\n duration: 'Durée moyenne de visite',\n durationPrev: 'Durée moyenne de visite (période précédente)',\n views: 'Pages vues',\n viewsPrev: 'Pages vues (période précédente)',\n visitors: 'Visiteurs',\n visitorsPrev: 'Visiteurs (période précédente)',\n visits: 'Visites',\n visitsPrev: 'Visites (période précédente)',\n },\n topPages: 'Pages les plus vues',\n topCountries: 'Principaux pays',\n messages: {\n error: 'Impossible de charger les statistiques. Vérifiez la configuration Umami.',\n loading: 'Chargement des statistiques…',\n noCountries: 'Aucun pays sur cette période.',\n noPages: 'Aucune page sur cette période.',\n },\n },\n}\n","import type { Access } from 'payload'\n\nexport type UmamiAccess = {\n /**\n * Who can see the analytics. Evaluated when rendering the dashboard widget\n * (denied users get nothing) and again in the report endpoint.\n * Defaults to any authenticated user.\n */\n read?: Access\n}\n\nexport type UmamiRange = '24h' | '7d' | '30d' | '90d'\n\nexport const UMAMI_RANGES: UmamiRange[] = ['24h', '7d', '30d', '90d']\n\n/** A single `{ x, y }` point as returned by Umami metrics/pageviews. */\nexport type UmamiPoint = {\n x: string\n y: number\n}\n\n/** Response of `GET /websites/:id/stats`. */\nexport type UmamiStats = {\n pageviews: number\n visitors: number\n visits: number\n bounces: number\n totaltime: number\n}\n\n/** Response of `GET /websites/:id/pageviews`. */\nexport type UmamiSeries = {\n /** Views over time. */\n pageviews: UmamiPoint[]\n /** Visitor sessions over time. */\n sessions: UmamiPoint[]\n}\n\n/** The combined payload returned by the `/plugin-umami/report` endpoint. */\nexport type UmamiReport = {\n range: UmamiRange\n stats: UmamiStats\n /** Same stats over the period immediately before the selected range. */\n prevStats: UmamiStats\n topPages: UmamiPoint[]\n topCountries: UmamiPoint[]\n series: UmamiSeries\n}\n\n/**\n * A stat card shown in the dashboard. `duration` is the average visit\n * duration (`totaltime / visits`). The `Prev` variants show the same metric\n * over the period immediately before the selected range.\n */\nexport type UmamiStatId =\n | 'bounces'\n | 'bouncesPrev'\n | 'duration'\n | 'durationPrev'\n | 'views'\n | 'viewsPrev'\n | 'visitors'\n | 'visitorsPrev'\n | 'visits'\n | 'visitsPrev'\n\nexport const DEFAULT_STATS: UmamiStatId[] = ['visitors', 'visitorsPrev', 'views', 'viewsPrev']\n\n/** Props passed from the plugin to the dashboard client component. */\nexport type UmamiDashboardProps = {\n defaultRange?: UmamiRange\n showRangeSelector?: boolean\n stats?: UmamiStatId[]\n}\n","'use client'\n\nimport React from 'react'\n\nexport type DashboardSkeletonProps = {\n /** How many stat cards the loaded dashboard will show. */\n statCount: number\n /** Announced to screen readers while the report is in flight. */\n label: string\n}\n\n/**\n * Placeholder shown while the report loads. It mirrors the real grid — same\n * card classes, same stat count, same 280px chart body — so the section keeps\n * its height when the data arrives instead of the panel jumping down.\n *\n * The bars deliberately reuse the text classes they stand in for\n * (`umami-stat__value`, `umami-top__name`, ...) and hold a non-breaking space:\n * they inherit the real font-size and line-height, so their heights match\n * without hard-coding pixel values that would drift as the styles change.\n */\nexport const DashboardSkeleton = ({ label, statCount }: DashboardSkeletonProps) => (\n <div aria-busy=\"true\" aria-label={label} className=\"umami-grid\" role=\"status\">\n {Array.from({ length: statCount }, (_, index) => (\n <div className=\"umami-card umami-stat\" key={index}>\n <span className=\"umami-stat__label umami-skeleton umami-skeleton--label\"> </span>\n <span className=\"umami-stat__value umami-skeleton umami-skeleton--value\"> </span>\n </div>\n ))}\n <div className=\"umami-card umami-chart\">\n <div className=\"umami-chart__header\">\n <span className=\"umami-dashboard__select umami-skeleton umami-skeleton--select\"> </span>\n </div>\n <div className=\"umami-skeleton umami-skeleton--chart\" />\n </div>\n {[0, 1].map((card) => (\n <div className=\"umami-card umami-top\" key={card}>\n <span className=\"umami-card__title umami-skeleton umami-skeleton--title\"> </span>\n <ol className=\"umami-top__list\">\n {Array.from({ length: 5 }, (_, row) => (\n <li className=\"umami-top__row\" key={row}>\n <span className=\"umami-top__name umami-skeleton\"> </span>\n </li>\n ))}\n </ol>\n </div>\n ))}\n </div>\n)\n","/**\n * Scoped styles for the Umami dashboard, injected via a `<style>` tag. Series\n * colors are categorical slots 1 (blue) & 2 (aqua) from the validated data-viz\n * palette, stepped per surface; chrome uses Payload's own admin theme variables\n * so the widgets match the panel in both light and dark themes.\n */\nexport const dashboardStyles = `\n.umami-dashboard {\n --umami-series-views: #2a78d6;\n --umami-series-visitors: #1baf7a;\n --umami-grid: #e1e0d9;\n --umami-axis: #c3c2b7;\n --umami-muted: #898781;\n container-type: inline-size;\n margin-bottom: 2rem;\n}\n@media (prefers-color-scheme: dark) {\n .umami-dashboard {\n --umami-series-views: #3987e5;\n --umami-series-visitors: #199e70;\n --umami-grid: #2c2c2a;\n --umami-axis: #383835;\n }\n}\nhtml[data-theme='dark'] .umami-dashboard {\n --umami-series-views: #3987e5;\n --umami-series-visitors: #199e70;\n --umami-grid: #2c2c2a;\n --umami-axis: #383835;\n}\nhtml[data-theme='light'] .umami-dashboard {\n --umami-series-views: #2a78d6;\n --umami-series-visitors: #1baf7a;\n --umami-grid: #e1e0d9;\n --umami-axis: #c3c2b7;\n}\n.umami-dashboard__header {\n align-items: center;\n display: flex;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n// .umami-dashboard__title { font-size: 1.15rem; font-weight: 600; margin: 0; }\n.umami-dashboard__select {\n background: var(--theme-elevation-50);\n border: 1px solid var(--theme-elevation-150);\n border-radius: 4px;\n color: var(--theme-text);\n padding: 0.4rem 0.6rem;\n}\n.umami-grid { display: grid; gap: 1rem; grid-template-columns: repeat(4, 1fr); }\n.umami-card {\n background: var(--theme-elevation-50);\n border: 1px solid var(--theme-elevation-100);\n border-radius: 6px;\n padding: 1rem;\n}\n.umami-card__title { color: var(--theme-elevation-800); font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }\n.umami-stat { display: flex; flex-direction: column; gap: 0.35rem; grid-column: span 1; }\n.umami-stat__label { color: var(--theme-elevation-600); font-size: 0.85rem; }\n.umami-stat__value { font-size: 2rem; font-weight: 700; line-height: 1; }\n.umami-chart { grid-column: span 4; }\n.umami-chart__header { display: flex; justify-content: flex-end; margin-bottom: 0.75rem; }\n.umami-top { grid-column: span 2; }\n.umami-top__list { list-style: none; margin: 0.75rem 0 0; padding: 0; }\n.umami-top__row { align-items: baseline; display: flex; gap: 0.75rem; justify-content: space-between; padding: 0.3rem 0; }\n.umami-top__name { color: var(--theme-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.umami-top__value { color: var(--theme-elevation-800); font-variant-numeric: tabular-nums; font-weight: 600; }\n.umami-empty { color: var(--theme-elevation-500); margin: 0.75rem 0 0; }\n.umami-message { color: var(--theme-elevation-600); padding: 1rem 0; }\n/*\n * Loading placeholders. Each bar keeps the type metrics of the element it\n * stands in for (it carries that element's class too) and just paints over\n * the text, so the skeleton and the loaded dashboard are the same height.\n */\n.umami-skeleton {\n animation: umami-skeleton-pulse 1.4s ease-in-out infinite;\n background: var(--theme-elevation-100);\n border-radius: 4px;\n color: transparent;\n display: block;\n max-width: 100%;\n user-select: none;\n}\n.umami-skeleton--label { width: 5rem; }\n.umami-skeleton--value { width: 4.5rem; }\n.umami-skeleton--title { width: 7rem; }\n/* Matches the select it replaces: same box, but no border to double up on. */\n.umami-skeleton--select { border-color: transparent; width: 7rem; }\n/* The height ResponsiveContainer gives the chart in TrafficChart. */\n.umami-skeleton--chart { height: 280px; width: 100%; }\n@keyframes umami-skeleton-pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.55; }\n}\n@media (prefers-reduced-motion: reduce) {\n .umami-skeleton { animation: none; }\n}\n/* Responds to the widget's own width (it can be resized in the dashboard). */\n@container (max-width: 900px) {\n .umami-grid { grid-template-columns: repeat(2, 1fr); }\n .umami-stat, .umami-top { grid-column: span 1; }\n .umami-chart { grid-column: span 2; }\n}\n`\n","'use client'\n\nimport React from 'react'\n\nexport type StatCardProps = {\n label: string\n value: number\n /** How the value is rendered: a count or a duration in seconds. @default 'number' */\n format?: 'duration' | 'number'\n}\n\nconst compact = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })\nconst full = new Intl.NumberFormat()\n\n/** Formats seconds as `3m 24s` (or `1h 02m` past an hour). */\nconst formatDuration = (seconds: number): string => {\n const total = Math.round(seconds)\n const hours = Math.floor(total / 3600)\n const minutes = Math.floor((total % 3600) / 60)\n const secs = total % 60\n if (hours > 0) {\n return `${hours}h ${String(minutes).padStart(2, '0')}m`\n }\n return `${minutes}m ${String(secs).padStart(2, '0')}s`\n}\n\n/** A single headline figure (visitors, views, average visit duration, ...). */\nexport const StatCard = ({ format = 'number', label, value }: StatCardProps) => (\n <div className=\"umami-card umami-stat\">\n <span className=\"umami-stat__label\">{label}</span>\n <span className=\"umami-stat__value\" title={format === 'number' ? full.format(value) : undefined}>\n {format === 'duration' ? formatDuration(value) : compact.format(value)}\n </span>\n </div>\n)\n","'use client'\n\nimport React from 'react'\n\nimport type { UmamiPoint } from '../types.js'\n\nexport type TopListProps = {\n title: string\n items: UmamiPoint[]\n /** Message shown when there is no data in the range. */\n emptyLabel: string\n}\n\nconst full = new Intl.NumberFormat()\n\n/** A ranked \"top 5\" list (used for Top Pages and Top Countries). */\nexport const TopList = ({ emptyLabel, items, title }: TopListProps) => (\n <div className=\"umami-card umami-top\">\n <span className=\"umami-card__title\">{title}</span>\n {items.length === 0 ? (\n <p className=\"umami-empty\">{emptyLabel}</p>\n ) : (\n <ol className=\"umami-top__list\">\n {items.map((item) => (\n <li className=\"umami-top__row\" key={item.x}>\n <span className=\"umami-top__name\" title={item.x}>\n {item.x || '—'}\n </span>\n <span className=\"umami-top__value\">{full.format(item.y)}</span>\n </li>\n ))}\n </ol>\n )}\n </div>\n)\n","'use client'\n\nimport React, { useMemo, useState } from 'react'\nimport { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'\n\nimport type { UmamiRange, UmamiSeries } from '../types.js'\n\nimport { bucketGrid, floorToBucket, rangeToWindow } from '../range.js'\n\nexport type TrafficChartProps = {\n series: UmamiSeries\n range: UmamiRange\n /** Accessible label for the stat dropdown. */\n metricLabel: string\n viewsLabel: string\n visitorsLabel: string\n}\n\n/**\n * The stats Umami exposes as a time series. Visits over time is not\n * available: the `/pageviews` endpoint only returns pageviews and sessions\n * buckets (Umami's own dashboard chart has the same two).\n */\ntype ChartMetric = 'views' | 'visitors'\n\ntype Row = { t: number; views: number; visitors: number }\n\n/**\n * Umami only returns buckets that saw traffic, so the series is seeded with a\n * zeroed row per bucket in the range — a quiet day still gets its tick on the\n * axis instead of the chart closing the gap.\n */\nconst mergeSeries = (series: UmamiSeries, range: UmamiRange): Row[] => {\n const unit = rangeToWindow(range).unit\n const byTime = new Map<number, Row>(\n bucketGrid(range).map((t) => [t, { t, views: 0, visitors: 0 }]),\n )\n const ensure = (x: string): Row => {\n const t = floorToBucket(new Date(x).getTime(), unit)\n let row = byTime.get(t)\n if (!row) {\n row = { t, views: 0, visitors: 0 }\n byTime.set(t, row)\n }\n return row\n }\n for (const point of series.pageviews) ensure(point.x).views = point.y\n for (const point of series.sessions) ensure(point.x).visitors = point.y\n return [...byTime.values()].sort((a, b) => a.t - b.t)\n}\n\n/**\n * Traffic over time as a bar chart showing one stat at a time — views (the\n * default) or visitors, picked from an in-card dropdown. Single series, so no\n * legend; the color follows the stat (views = blue, visitors = aqua, the\n * series CSS vars defined by the dashboard). Chrome uses Payload's admin\n * theme variables.\n */\nexport const TrafficChart = ({\n metricLabel,\n range,\n series,\n viewsLabel,\n visitorsLabel,\n}: TrafficChartProps) => {\n const [metric, setMetric] = useState<ChartMetric>('views')\n const data = useMemo(() => mergeSeries(series, range), [series, range])\n\n const metricLabels: Record<ChartMetric, string> = {\n views: viewsLabel,\n visitors: visitorsLabel,\n }\n\n const formatTick = (t: number) => {\n const date = new Date(t)\n return range === '24h'\n ? date.toLocaleTimeString(undefined, { hour: '2-digit' })\n : date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })\n }\n\n /**\n * Fuller than the axis tick, since the tooltip has the room. Day-bucketed\n * ranges leave the time out entirely — every bucket starts at midnight, so a\n * time would only ever read \"00:00\" and suggest a precision the bucket\n * doesn't have.\n */\n const formatTooltipLabel = (t: number) => {\n const date = new Date(t)\n return range === '24h'\n ? date.toLocaleString()\n : date.toLocaleDateString(undefined, {\n day: 'numeric',\n month: 'long',\n weekday: 'short',\n year: 'numeric',\n })\n }\n\n return (\n <div className=\"umami-card umami-chart\">\n <div className=\"umami-chart__header\">\n <select\n aria-label={metricLabel}\n className=\"umami-dashboard__select\"\n onChange={(event) => setMetric(event.target.value as ChartMetric)}\n value={metric}\n >\n {(['views', 'visitors'] as const).map((value) => (\n <option key={value} value={value}>\n {metricLabels[value]}\n </option>\n ))}\n </select>\n </div>\n <ResponsiveContainer height={280} width=\"100%\">\n <BarChart data={data} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>\n <CartesianGrid stroke=\"var(--umami-grid)\" vertical={false} />\n <XAxis\n dataKey=\"t\"\n stroke=\"var(--umami-axis)\"\n tick={{ fill: 'var(--umami-muted)', fontSize: 12 }}\n tickFormatter={formatTick}\n tickLine={false}\n />\n <YAxis\n allowDecimals={false}\n stroke=\"var(--umami-axis)\"\n tick={{ fill: 'var(--umami-muted)', fontSize: 12 }}\n tickLine={false}\n width={44}\n />\n <Tooltip\n contentStyle={{\n background: 'var(--theme-elevation-50)',\n border: '1px solid var(--theme-elevation-150)',\n borderRadius: 4,\n color: 'var(--theme-text)',\n }}\n cursor={{ fill: 'var(--umami-grid)', opacity: 0.5 }}\n labelFormatter={(t) => formatTooltipLabel(Number(t))}\n />\n <Bar\n dataKey={metric}\n fill={metric === 'views' ? 'var(--umami-series-views)' : 'var(--umami-series-visitors)'}\n maxBarSize={40}\n name={metricLabels[metric]}\n radius={[4, 4, 0, 0]}\n />\n </BarChart>\n </ResponsiveContainer>\n </div>\n )\n}\n","import type { UmamiRange } from './types.js'\n\nimport { UMAMI_RANGES } from './types.js'\n\nconst DAY = 24 * 60 * 60 * 1000\n\nconst RANGE_MS: Record<UmamiRange, number> = {\n '24h': DAY,\n '7d': 7 * DAY,\n '30d': 30 * DAY,\n '90d': 90 * DAY,\n}\n\nexport type UmamiWindow = {\n startAt: number\n endAt: number\n unit: 'hour' | 'day'\n}\n\nexport const isUmamiRange = (value: unknown): value is UmamiRange =>\n typeof value === 'string' && (UMAMI_RANGES as string[]).includes(value)\n\n/**\n * Turns a range token into the absolute `startAt`/`endAt` (ms unix) window and\n * the time-series bucket `unit` Umami expects. `24h` buckets by hour, the\n * longer ranges by day.\n */\nexport const rangeToWindow = (range: UmamiRange, now: number = Date.now()): UmamiWindow => ({\n startAt: now - RANGE_MS[range],\n endAt: now,\n unit: range === '24h' ? 'hour' : 'day',\n})\n\n/**\n * Floors a timestamp to the start of its bucket in local time. Local rather\n * than UTC so the buckets line up with the ones Umami returns (it buckets in\n * the timezone we pass) and with how the chart formats its ticks.\n */\nexport const floorToBucket = (t: number, unit: UmamiWindow['unit']): number => {\n const date = new Date(t)\n if (unit === 'hour') {\n date.setMinutes(0, 0, 0)\n } else {\n date.setHours(0, 0, 0, 0)\n }\n return date.getTime()\n}\n\n/**\n * Every bucket start in the window, so the chart can render empty periods as\n * zeroes instead of dropping them — Umami omits buckets with no traffic.\n * Steps with `Date` setters rather than fixed millisecond arithmetic so a DST\n * change doesn't shift every following bucket off the hour/midnight.\n */\nexport const bucketGrid = (range: UmamiRange, now: number = Date.now()): number[] => {\n const { endAt, startAt, unit } = rangeToWindow(range, now)\n const last = floorToBucket(endAt, unit)\n const cursor = new Date(floorToBucket(startAt, unit))\n const buckets: number[] = []\n\n while (cursor.getTime() <= last) {\n buckets.push(cursor.getTime())\n if (unit === 'hour') {\n cursor.setHours(cursor.getHours() + 1)\n } else {\n cursor.setDate(cursor.getDate() + 1)\n }\n }\n\n return buckets\n}\n"],"mappings":"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","names":["useMemo","useState","jsx","jsxs","jsx","jsxs","full","jsx","jsxs","jsx","jsxs","useMemo","useState"]}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { APIError } from "payload";
|
|
|
3
3
|
|
|
4
4
|
// src/types.ts
|
|
5
5
|
var UMAMI_RANGES = ["24h", "7d", "30d", "90d"];
|
|
6
|
-
var DEFAULT_STATS = ["visitors", "
|
|
6
|
+
var DEFAULT_STATS = ["visitors", "visitorsPrev", "views", "viewsPrev"];
|
|
7
7
|
|
|
8
8
|
// src/range.ts
|
|
9
9
|
var DAY = 24 * 60 * 60 * 1e3;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/endpoints/report.ts","../src/types.ts","../src/range.ts","../src/umami/client.ts","../src/translations/en.ts","../src/translations/fr.ts","../src/index.ts"],"sourcesContent":["import type { Access, Endpoint, PayloadRequest } from 'payload'\n\nimport { APIError } from 'payload'\n\nimport type { UmamiReport } from '../types.js'\nimport type { UmamiCredentials } from '../umami/client.js'\n\nimport { isUmamiRange, rangeToWindow } from '../range.js'\nimport { createUmamiClient, UmamiError } from '../umami/client.js'\n\nexport const REPORT_PATH = '/plugin-umami/report'\n\n/**\n * Builds the GET `/api/plugin-umami/report` endpoint. It authenticates the\n * admin user, resolves the requested range into a Umami time window, fetches\n * stats + top pages + top countries + the time series server-side, and returns\n * them as one combined report. Umami credentials stay on the server.\n */\nexport const reportEndpoint = (\n credentials: UmamiCredentials,\n defaultRange: UmamiReport['range'],\n readAccess: Access,\n): Endpoint => {\n // Created once so the self-hosted Bearer token is cached across requests.\n const client = createUmamiClient(credentials)\n\n return {\n path: REPORT_PATH,\n method: 'get',\n handler: async (req: PayloadRequest): Promise<Response> => {\n if (!req.user) {\n throw new APIError('Unauthorized', 401)\n }\n\n // Same access rule that gates the dashboard widget.\n if (!(await readAccess({ req }))) {\n throw new APIError('Forbidden', 403)\n }\n\n const requested = req.query?.range\n const range = isUmamiRange(requested) ? requested : defaultRange\n const { endAt, startAt, unit } = rangeToWindow(range)\n // The period immediately before the selected range, same length.\n const prevStartAt = startAt - (endAt - startAt)\n\n try {\n const [stats, prevStats, topPages, topCountries, series] = await Promise.all([\n client.getStats(startAt, endAt),\n client.getStats(prevStartAt, startAt),\n client.getTopPages(startAt, endAt),\n client.getTopCountries(startAt, endAt),\n client.getSeries(startAt, endAt, unit),\n ])\n\n const report: UmamiReport = { range, stats, prevStats, topPages, topCountries, series }\n return Response.json(report)\n } catch (error) {\n const status = error instanceof UmamiError ? error.status : 502\n req.payload.logger.error(\n { err: error, plugin: '@composius/payload-plugin-umami' },\n 'Failed to fetch Umami report',\n )\n return Response.json({ error: 'Failed to fetch Umami analytics' }, { status })\n }\n },\n }\n}\n","import type { Access } from 'payload'\n\nexport type UmamiAccess = {\n /**\n * Who can see the analytics. Evaluated when rendering the dashboard widget\n * (denied users get nothing) and again in the report endpoint.\n * Defaults to any authenticated user.\n */\n read?: Access\n}\n\nexport type UmamiRange = '24h' | '7d' | '30d' | '90d'\n\nexport const UMAMI_RANGES: UmamiRange[] = ['24h', '7d', '30d', '90d']\n\n/** A single `{ x, y }` point as returned by Umami metrics/pageviews. */\nexport type UmamiPoint = {\n x: string\n y: number\n}\n\n/** Response of `GET /websites/:id/stats`. */\nexport type UmamiStats = {\n pageviews: number\n visitors: number\n visits: number\n bounces: number\n totaltime: number\n}\n\n/** Response of `GET /websites/:id/pageviews`. */\nexport type UmamiSeries = {\n /** Views over time. */\n pageviews: UmamiPoint[]\n /** Visitor sessions over time. */\n sessions: UmamiPoint[]\n}\n\n/** The combined payload returned by the `/plugin-umami/report` endpoint. */\nexport type UmamiReport = {\n range: UmamiRange\n stats: UmamiStats\n /** Same stats over the period immediately before the selected range. */\n prevStats: UmamiStats\n topPages: UmamiPoint[]\n topCountries: UmamiPoint[]\n series: UmamiSeries\n}\n\n/**\n * A stat card shown in the dashboard. `duration` is the average visit\n * duration (`totaltime / visits`). The `Prev` variants show the same metric\n * over the period immediately before the selected range.\n */\nexport type UmamiStatId =\n | 'bounces'\n | 'bouncesPrev'\n | 'duration'\n | 'durationPrev'\n | 'views'\n | 'viewsPrev'\n | 'visitors'\n | 'visitorsPrev'\n | 'visits'\n | 'visitsPrev'\n\nexport const DEFAULT_STATS: UmamiStatId[] = ['visitors', 'views', 'visitorsPrev', 'viewsPrev']\n\n/** Props passed from the plugin to the dashboard client component. */\nexport type UmamiDashboardProps = {\n defaultRange?: UmamiRange\n showRangeSelector?: boolean\n stats?: UmamiStatId[]\n}\n","import type { UmamiRange } from './types.js'\n\nimport { UMAMI_RANGES } from './types.js'\n\nconst DAY = 24 * 60 * 60 * 1000\n\nconst RANGE_MS: Record<UmamiRange, number> = {\n '24h': DAY,\n '7d': 7 * DAY,\n '30d': 30 * DAY,\n '90d': 90 * DAY,\n}\n\nexport type UmamiWindow = {\n startAt: number\n endAt: number\n unit: 'hour' | 'day'\n}\n\nexport const isUmamiRange = (value: unknown): value is UmamiRange =>\n typeof value === 'string' && (UMAMI_RANGES as string[]).includes(value)\n\n/**\n * Turns a range token into the absolute `startAt`/`endAt` (ms unix) window and\n * the time-series bucket `unit` Umami expects. `24h` buckets by hour, the\n * longer ranges by day.\n */\nexport const rangeToWindow = (range: UmamiRange, now: number = Date.now()): UmamiWindow => ({\n startAt: now - RANGE_MS[range],\n endAt: now,\n unit: range === '24h' ? 'hour' : 'day',\n})\n","import type { UmamiPoint, UmamiSeries, UmamiStats } from '../types.js'\n\nexport type UmamiCredentials = {\n websiteId: string\n /** Umami Cloud API key (`x-umami-api-key`). Takes precedence over username/password. */\n apiKey?: string\n /** Self-hosted username (used with `password`). */\n username?: string\n /** Self-hosted password. */\n password?: string\n /** API base URL. Defaults to Umami Cloud (`https://api.umami.is`). */\n baseUrl?: string\n /** IANA timezone for time-series buckets. Defaults to the server timezone. */\n timezone?: string\n}\n\nconst CLOUD_BASE = 'https://api.umami.is'\n\n/**\n * Top-pages metric type. Umami v3 removed `url` in favor of `path`\n * (breaking change from v2). Targets v3.x.\n */\nconst TOP_PAGES_METRIC = 'path'\n\nexport class UmamiError extends Error {\n status: number\n constructor(message: string, status: number) {\n super(message)\n this.name = 'UmamiError'\n this.status = status\n }\n}\n\nexport type UmamiClient = {\n getStats: (startAt: number, endAt: number) => Promise<UmamiStats>\n getTopPages: (startAt: number, endAt: number, limit?: number) => Promise<UmamiPoint[]>\n getTopCountries: (startAt: number, endAt: number, limit?: number) => Promise<UmamiPoint[]>\n getSeries: (\n startAt: number,\n endAt: number,\n unit: 'hour' | 'day',\n ) => Promise<UmamiSeries>\n}\n\n/**\n * Server-side Umami API client. Supports both Umami Cloud (`x-umami-api-key`)\n * and self-hosted (login → cached Bearer token, re-auth once on 401).\n * Credentials never leave this module — the browser talks to the Payload\n * proxy endpoint, not to Umami.\n */\nexport const createUmamiClient = (credentials: UmamiCredentials): UmamiClient => {\n const { apiKey, baseUrl, password, timezone, username, websiteId } = credentials\n const isCloud = Boolean(apiKey)\n // Cloud data endpoints live under /v1; self-hosted under /api.\n const root = `${(baseUrl ?? CLOUD_BASE).replace(/\\/$/, '')}${isCloud ? '/v1' : '/api'}`\n const tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone\n\n let token: string | null = null\n\n const login = async (): Promise<string> => {\n const response = await fetch(`${(baseUrl ?? CLOUD_BASE).replace(/\\/$/, '')}/api/auth/login`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ password, username }),\n })\n if (!response.ok) {\n throw new UmamiError(`Umami login failed (${response.status})`, response.status)\n }\n const data = (await response.json()) as { token: string }\n token = data.token\n return token\n }\n\n const authHeader = async (): Promise<Record<string, string>> => {\n if (isCloud) {\n return { 'x-umami-api-key': apiKey as string }\n }\n if (!token) {\n await login()\n }\n return { Authorization: `Bearer ${token}` }\n }\n\n const request = async <T>(path: string, params: Record<string, string | number>): Promise<T> => {\n const query = new URLSearchParams(\n Object.entries(params).map(([key, value]) => [key, String(value)]),\n ).toString()\n const url = `${root}/websites/${websiteId}${path}?${query}`\n\n const send = async () =>\n fetch(url, { headers: { Accept: 'application/json', ...(await authHeader()) } })\n\n let response = await send()\n\n // Self-hosted Bearer tokens expire; re-authenticate once on 401.\n if (response.status === 401 && !isCloud) {\n token = null\n response = await send()\n }\n\n if (!response.ok) {\n throw new UmamiError(`Umami request failed (${response.status})`, response.status)\n }\n\n return (await response.json()) as T\n }\n\n return {\n getStats: (startAt, endAt) => request<UmamiStats>('/stats', { startAt, endAt }),\n getTopPages: (startAt, endAt, limit = 5) =>\n request<UmamiPoint[]>('/metrics', { startAt, endAt, type: TOP_PAGES_METRIC, limit }),\n getTopCountries: (startAt, endAt, limit = 5) =>\n request<UmamiPoint[]>('/metrics', { startAt, endAt, type: 'country', limit }),\n getSeries: (startAt, endAt, unit) =>\n request<UmamiSeries>('/pageviews', { startAt, endAt, unit, timezone: tz }),\n }\n}\n","export const en = {\n umami: {\n title: 'Analytics',\n timeRange: 'Time range',\n chartMetric: 'Chart stat',\n ranges: {\n '24h': 'Last 24 hours',\n '7d': 'Last 7 days',\n '30d': 'Last 30 days',\n '90d': 'Last 90 days',\n },\n stats: {\n bounces: 'Bounces',\n bouncesPrev: 'Bounces (last period)',\n duration: 'Avg. visit duration',\n durationPrev: 'Avg. visit duration (last period)',\n views: 'Views',\n viewsPrev: 'Views (last period)',\n visitors: 'Visitors',\n visitorsPrev: 'Visitors (last period)',\n visits: 'Visits',\n visitsPrev: 'Visits (last period)',\n },\n topPages: 'Top pages',\n topCountries: 'Top countries',\n messages: {\n error: 'Could not load analytics. Check the Umami configuration.',\n loading: 'Loading analytics…',\n noCountries: 'No countries in this range.',\n noPages: 'No pages in this range.',\n },\n },\n}\n","import type { Translation } from './index.js'\n\nexport const fr: Translation = {\n umami: {\n title: 'Statistiques',\n timeRange: 'Période',\n chartMetric: 'Métrique du graphique',\n ranges: {\n '24h': 'Dernières 24 heures',\n '7d': '7 derniers jours',\n '30d': '30 derniers jours',\n '90d': '90 derniers jours',\n },\n stats: {\n bounces: 'Rebonds',\n bouncesPrev: 'Rebonds (période précédente)',\n duration: 'Durée moyenne de visite',\n durationPrev: 'Durée moyenne de visite (période précédente)',\n views: 'Pages vues',\n viewsPrev: 'Pages vues (période précédente)',\n visitors: 'Visiteurs',\n visitorsPrev: 'Visiteurs (période précédente)',\n visits: 'Visites',\n visitsPrev: 'Visites (période précédente)',\n },\n topPages: 'Pages les plus vues',\n topCountries: 'Principaux pays',\n messages: {\n error: 'Impossible de charger les statistiques. Vérifiez la configuration Umami.',\n loading: 'Chargement des statistiques…',\n noCountries: 'Aucun pays sur cette période.',\n noPages: 'Aucune page sur cette période.',\n },\n },\n}\n","import type { Access, Config } from 'payload'\n\nimport type { UmamiAccess, UmamiRange, UmamiStatId } from './types.js'\n\nimport { reportEndpoint } from './endpoints/report.js'\nimport { en } from './translations/en.js'\nimport { fr } from './translations/fr.js'\nimport { DEFAULT_STATS } from './types.js'\n\nexport type { UmamiAccess, UmamiRange, UmamiReport, UmamiStatId } from './types.js'\n\n/** Default `read` access: any authenticated user. */\nexport const authenticated: Access = ({ req: { user } }) => Boolean(user)\n\nexport type ComposiusPayloadPluginUmamiConfig = {\n /**\n * Access control for the analytics, per operation. Only `read` exists for\n * now. Default: `read` requires an authenticated user.\n */\n access?: UmamiAccess\n /** Umami website ID (UUID) to report on. */\n websiteId: string\n /**\n * Umami Cloud API key (sent as `x-umami-api-key`). Use this OR\n * `username`/`password`. Takes precedence when both are set.\n */\n apiKey?: string\n /** Self-hosted username (used with `password`). */\n username?: string\n /** Self-hosted password (used with `username`). */\n password?: string\n /**\n * API base URL. Defaults to Umami Cloud (`https://api.umami.is`, data paths\n * under `/v1`). For self-hosted, set your instance URL (data paths under\n * `/api`).\n * @default 'https://api.umami.is'\n */\n baseUrl?: string\n /**\n * IANA timezone used to bucket the time-series chart.\n * @default the server timezone\n */\n timezone?: string\n /**\n * Initial time range shown in the dashboard.\n * @default '7d'\n */\n defaultRange?: UmamiRange\n /**\n * Show the in-panel range selector (24h / 7d / 30d / 90d).\n * @default true\n */\n showRangeSelector?: boolean\n /**\n * Which stat cards to show, in order. Available: `visitors`, `views`,\n * `visits`, `bounces`, `duration` (average visit duration), and their\n * `...Prev` variants for the period immediately before the selected range.\n * @default ['visitors', 'views', 'visitorsPrev', 'viewsPrev']\n */\n stats?: UmamiStatId[]\n /** Leaves the config untouched. */\n disabled?: boolean\n}\n\nconst COMPONENT_PATH = '@composius/payload-plugin-umami/rsc'\nconst COMPONENT_EXPORT = 'UmamiWidget'\nexport const WIDGET_SLUG = 'umami'\n\n/**\n * Adds an Umami web-analytics widget (stat cards, top pages, top countries,\n * and a visitors/views time chart) to the Payload admin dashboard, registered\n * through the modular-dashboard API (`admin.dashboard`, experimental in\n * Payload 3.x) so it can be dragged, resized, removed and re-added like the\n * built-in collections widget. Analytics are fetched server-side through a\n * proxy endpoint, so Umami credentials never reach the browser.\n */\nexport const ComposiusPayloadPluginUmami =\n (pluginOptions: ComposiusPayloadPluginUmamiConfig) =>\n (config: Config): Config => {\n if (pluginOptions.disabled) {\n return config\n }\n\n const { apiKey, password, username, websiteId } = pluginOptions\n const hasCredentials = Boolean(apiKey) || Boolean(username && password)\n\n // Credentials usually come from env vars, which may be absent (local dev,\n // CI). Degrade to no widgets instead of crashing the config build.\n if (!websiteId || !hasCredentials) {\n console.warn(\n '[@composius/payload-plugin-umami] `websiteId` or credentials missing, analytics widgets are disabled.',\n )\n return config\n }\n\n const defaultRange = pluginOptions.defaultRange ?? '7d'\n const readAccess = pluginOptions.access?.read ?? authenticated\n\n config.endpoints = [\n ...(config.endpoints ?? []),\n reportEndpoint(\n {\n apiKey,\n baseUrl: pluginOptions.baseUrl,\n password,\n timezone: pluginOptions.timezone,\n username,\n websiteId,\n },\n defaultRange,\n readAccess,\n ),\n ]\n\n if (!config.admin) config.admin = {}\n\n // Registered as a modular-dashboard widget so users can drag, resize,\n // remove and re-add it from the dashboard's edit mode, like the built-in\n // collections widget.\n if (!config.admin.dashboard) config.admin.dashboard = { widgets: [] }\n config.admin.dashboard.widgets.push({\n slug: WIDGET_SLUG,\n // A server component (UmamiWidget) gates rendering on `access.read`,\n // then hands the serializable props to the client dashboard.\n Component: {\n path: COMPONENT_PATH,\n exportName: COMPONENT_EXPORT,\n clientProps: {\n defaultRange,\n showRangeSelector: pluginOptions.showRangeSelector ?? true,\n stats: pluginOptions.stats ?? DEFAULT_STATS,\n },\n // serverProps stay on the server — safe to carry the access function.\n serverProps: {\n access: readAccess,\n },\n },\n label: { en: en.umami.title, fr: fr.umami.title },\n minWidth: 'medium',\n })\n\n // Show the widget by default, above the collections widget Payload adds\n // during sanitization. A user-defined layout is left untouched — the\n // widget then only appears in the \"add widget\" drawer.\n config.admin.dashboard.defaultLayout ??= [\n { widgetSlug: WIDGET_SLUG, width: 'full' },\n { widgetSlug: 'collections', width: 'full' },\n ]\n\n return config\n }\n"],"mappings":";AAEA,SAAS,gBAAgB;;;ACWlB,IAAM,eAA6B,CAAC,OAAO,MAAM,OAAO,KAAK;AAqD7D,IAAM,gBAA+B,CAAC,YAAY,SAAS,gBAAgB,WAAW;;;AC9D7F,IAAM,MAAM,KAAK,KAAK,KAAK;AAE3B,IAAM,WAAuC;AAAA,EAC3C,OAAO;AAAA,EACP,MAAM,IAAI;AAAA,EACV,OAAO,KAAK;AAAA,EACZ,OAAO,KAAK;AACd;AAQO,IAAM,eAAe,CAAC,UAC3B,OAAO,UAAU,YAAa,aAA0B,SAAS,KAAK;AAOjE,IAAM,gBAAgB,CAAC,OAAmB,MAAc,KAAK,IAAI,OAAoB;AAAA,EAC1F,SAAS,MAAM,SAAS,KAAK;AAAA,EAC7B,OAAO;AAAA,EACP,MAAM,UAAU,QAAQ,SAAS;AACnC;;;ACfA,IAAM,aAAa;AAMnB,IAAM,mBAAmB;AAElB,IAAM,aAAN,cAAyB,MAAM;AAAA,EACpC;AAAA,EACA,YAAY,SAAiB,QAAgB;AAC3C,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;AAmBO,IAAM,oBAAoB,CAAC,gBAA+C;AAC/E,QAAM,EAAE,QAAQ,SAAS,UAAU,UAAU,UAAU,UAAU,IAAI;AACrE,QAAM,UAAU,QAAQ,MAAM;AAE9B,QAAM,OAAO,IAAI,WAAW,YAAY,QAAQ,OAAO,EAAE,CAAC,GAAG,UAAU,QAAQ,MAAM;AACrF,QAAM,KAAK,YAAY,KAAK,eAAe,EAAE,gBAAgB,EAAE;AAE/D,MAAI,QAAuB;AAE3B,QAAM,QAAQ,YAA6B;AACzC,UAAM,WAAW,MAAM,MAAM,IAAI,WAAW,YAAY,QAAQ,OAAO,EAAE,CAAC,mBAAmB;AAAA,MAC3F,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,SAAS,CAAC;AAAA,IAC7C,CAAC;AACD,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI,WAAW,uBAAuB,SAAS,MAAM,KAAK,SAAS,MAAM;AAAA,IACjF;AACA,UAAM,OAAQ,MAAM,SAAS,KAAK;AAClC,YAAQ,KAAK;AACb,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,YAA6C;AAC9D,QAAI,SAAS;AACX,aAAO,EAAE,mBAAmB,OAAiB;AAAA,IAC/C;AACA,QAAI,CAAC,OAAO;AACV,YAAM,MAAM;AAAA,IACd;AACA,WAAO,EAAE,eAAe,UAAU,KAAK,GAAG;AAAA,EAC5C;AAEA,QAAM,UAAU,OAAU,MAAc,WAAwD;AAC9F,UAAM,QAAQ,IAAI;AAAA,MAChB,OAAO,QAAQ,MAAM,EAAE,IAAI,CAAC,CAAC,KAAK,KAAK,MAAM,CAAC,KAAK,OAAO,KAAK,CAAC,CAAC;AAAA,IACnE,EAAE,SAAS;AACX,UAAM,MAAM,GAAG,IAAI,aAAa,SAAS,GAAG,IAAI,IAAI,KAAK;AAEzD,UAAM,OAAO,YACX,MAAM,KAAK,EAAE,SAAS,EAAE,QAAQ,oBAAoB,GAAI,MAAM,WAAW,EAAG,EAAE,CAAC;AAEjF,QAAI,WAAW,MAAM,KAAK;AAG1B,QAAI,SAAS,WAAW,OAAO,CAAC,SAAS;AACvC,cAAQ;AACR,iBAAW,MAAM,KAAK;AAAA,IACxB;AAEA,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI,WAAW,yBAAyB,SAAS,MAAM,KAAK,SAAS,MAAM;AAAA,IACnF;AAEA,WAAQ,MAAM,SAAS,KAAK;AAAA,EAC9B;AAEA,SAAO;AAAA,IACL,UAAU,CAAC,SAAS,UAAU,QAAoB,UAAU,EAAE,SAAS,MAAM,CAAC;AAAA,IAC9E,aAAa,CAAC,SAAS,OAAO,QAAQ,MACpC,QAAsB,YAAY,EAAE,SAAS,OAAO,MAAM,kBAAkB,MAAM,CAAC;AAAA,IACrF,iBAAiB,CAAC,SAAS,OAAO,QAAQ,MACxC,QAAsB,YAAY,EAAE,SAAS,OAAO,MAAM,WAAW,MAAM,CAAC;AAAA,IAC9E,WAAW,CAAC,SAAS,OAAO,SAC1B,QAAqB,cAAc,EAAE,SAAS,OAAO,MAAM,UAAU,GAAG,CAAC;AAAA,EAC7E;AACF;;;AH1GO,IAAM,cAAc;AAQpB,IAAM,iBAAiB,CAC5B,aACA,cACA,eACa;AAEb,QAAM,SAAS,kBAAkB,WAAW;AAE5C,SAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,SAAS,OAAO,QAA2C;AACzD,UAAI,CAAC,IAAI,MAAM;AACb,cAAM,IAAI,SAAS,gBAAgB,GAAG;AAAA,MACxC;AAGA,UAAI,CAAE,MAAM,WAAW,EAAE,IAAI,CAAC,GAAI;AAChC,cAAM,IAAI,SAAS,aAAa,GAAG;AAAA,MACrC;AAEA,YAAM,YAAY,IAAI,OAAO;AAC7B,YAAM,QAAQ,aAAa,SAAS,IAAI,YAAY;AACpD,YAAM,EAAE,OAAO,SAAS,KAAK,IAAI,cAAc,KAAK;AAEpD,YAAM,cAAc,WAAW,QAAQ;AAEvC,UAAI;AACF,cAAM,CAAC,OAAO,WAAW,UAAU,cAAc,MAAM,IAAI,MAAM,QAAQ,IAAI;AAAA,UAC3E,OAAO,SAAS,SAAS,KAAK;AAAA,UAC9B,OAAO,SAAS,aAAa,OAAO;AAAA,UACpC,OAAO,YAAY,SAAS,KAAK;AAAA,UACjC,OAAO,gBAAgB,SAAS,KAAK;AAAA,UACrC,OAAO,UAAU,SAAS,OAAO,IAAI;AAAA,QACvC,CAAC;AAED,cAAM,SAAsB,EAAE,OAAO,OAAO,WAAW,UAAU,cAAc,OAAO;AACtF,eAAO,SAAS,KAAK,MAAM;AAAA,MAC7B,SAAS,OAAO;AACd,cAAM,SAAS,iBAAiB,aAAa,MAAM,SAAS;AAC5D,YAAI,QAAQ,OAAO;AAAA,UACjB,EAAE,KAAK,OAAO,QAAQ,kCAAkC;AAAA,UACxD;AAAA,QACF;AACA,eAAO,SAAS,KAAK,EAAE,OAAO,kCAAkC,GAAG,EAAE,OAAO,CAAC;AAAA,MAC/E;AAAA,IACF;AAAA,EACF;AACF;;;AIlEO,IAAM,KAAK;AAAA,EAChB,OAAO;AAAA,IACL,OAAO;AAAA,IACP,WAAW;AAAA,IACX,aAAa;AAAA,IACb,QAAQ;AAAA,MACN,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO;AAAA,MACP,OAAO;AAAA,IACT;AAAA,IACA,OAAO;AAAA,MACL,SAAS;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,cAAc;AAAA,MACd,OAAO;AAAA,MACP,WAAW;AAAA,MACX,UAAU;AAAA,MACV,cAAc;AAAA,MACd,QAAQ;AAAA,MACR,YAAY;AAAA,IACd;AAAA,IACA,UAAU;AAAA,IACV,cAAc;AAAA,IACd,UAAU;AAAA,MACR,OAAO;AAAA,MACP,SAAS;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,IACX;AAAA,EACF;AACF;;;AC9BO,IAAM,KAAkB;AAAA,EAC7B,OAAO;AAAA,IACL,OAAO;AAAA,IACP,WAAW;AAAA,IACX,aAAa;AAAA,IACb,QAAQ;AAAA,MACN,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO;AAAA,MACP,OAAO;AAAA,IACT;AAAA,IACA,OAAO;AAAA,MACL,SAAS;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,cAAc;AAAA,MACd,OAAO;AAAA,MACP,WAAW;AAAA,MACX,UAAU;AAAA,MACV,cAAc;AAAA,MACd,QAAQ;AAAA,MACR,YAAY;AAAA,IACd;AAAA,IACA,UAAU;AAAA,IACV,cAAc;AAAA,IACd,UAAU;AAAA,MACR,OAAO;AAAA,MACP,SAAS;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,IACX;AAAA,EACF;AACF;;;ACtBO,IAAM,gBAAwB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,QAAQ,IAAI;AAoDxE,IAAM,iBAAiB;AACvB,IAAM,mBAAmB;AAClB,IAAM,cAAc;AAUpB,IAAM,8BACX,CAAC,kBACD,CAAC,WAA2B;AAC1B,MAAI,cAAc,UAAU;AAC1B,WAAO;AAAA,EACT;AAEA,QAAM,EAAE,QAAQ,UAAU,UAAU,UAAU,IAAI;AAClD,QAAM,iBAAiB,QAAQ,MAAM,KAAK,QAAQ,YAAY,QAAQ;AAItE,MAAI,CAAC,aAAa,CAAC,gBAAgB;AACjC,YAAQ;AAAA,MACN;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,cAAc,gBAAgB;AACnD,QAAM,aAAa,cAAc,QAAQ,QAAQ;AAEjD,SAAO,YAAY;AAAA,IACjB,GAAI,OAAO,aAAa,CAAC;AAAA,IACzB;AAAA,MACE;AAAA,QACE;AAAA,QACA,SAAS,cAAc;AAAA,QACvB;AAAA,QACA,UAAU,cAAc;AAAA,QACxB;AAAA,QACA;AAAA,MACF;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,MAAI,CAAC,OAAO,MAAO,QAAO,QAAQ,CAAC;AAKnC,MAAI,CAAC,OAAO,MAAM,UAAW,QAAO,MAAM,YAAY,EAAE,SAAS,CAAC,EAAE;AACpE,SAAO,MAAM,UAAU,QAAQ,KAAK;AAAA,IAClC,MAAM;AAAA;AAAA;AAAA,IAGN,WAAW;AAAA,MACT,MAAM;AAAA,MACN,YAAY;AAAA,MACZ,aAAa;AAAA,QACX;AAAA,QACA,mBAAmB,cAAc,qBAAqB;AAAA,QACtD,OAAO,cAAc,SAAS;AAAA,MAChC;AAAA;AAAA,MAEA,aAAa;AAAA,QACX,QAAQ;AAAA,MACV;AAAA,IACF;AAAA,IACA,OAAO,EAAE,IAAI,GAAG,MAAM,OAAO,IAAI,GAAG,MAAM,MAAM;AAAA,IAChD,UAAU;AAAA,EACZ,CAAC;AAKD,SAAO,MAAM,UAAU,kBAAkB;AAAA,IACvC,EAAE,YAAY,aAAa,OAAO,OAAO;AAAA,IACzC,EAAE,YAAY,eAAe,OAAO,OAAO;AAAA,EAC7C;AAEA,SAAO;AACT;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/endpoints/report.ts","../src/types.ts","../src/range.ts","../src/umami/client.ts","../src/translations/en.ts","../src/translations/fr.ts","../src/index.ts"],"sourcesContent":["import type { Access, Endpoint, PayloadRequest } from 'payload'\n\nimport { APIError } from 'payload'\n\nimport type { UmamiReport } from '../types.js'\nimport type { UmamiCredentials } from '../umami/client.js'\n\nimport { isUmamiRange, rangeToWindow } from '../range.js'\nimport { createUmamiClient, UmamiError } from '../umami/client.js'\n\nexport const REPORT_PATH = '/plugin-umami/report'\n\n/**\n * Builds the GET `/api/plugin-umami/report` endpoint. It authenticates the\n * admin user, resolves the requested range into a Umami time window, fetches\n * stats + top pages + top countries + the time series server-side, and returns\n * them as one combined report. Umami credentials stay on the server.\n */\nexport const reportEndpoint = (\n credentials: UmamiCredentials,\n defaultRange: UmamiReport['range'],\n readAccess: Access,\n): Endpoint => {\n // Created once so the self-hosted Bearer token is cached across requests.\n const client = createUmamiClient(credentials)\n\n return {\n path: REPORT_PATH,\n method: 'get',\n handler: async (req: PayloadRequest): Promise<Response> => {\n if (!req.user) {\n throw new APIError('Unauthorized', 401)\n }\n\n // Same access rule that gates the dashboard widget.\n if (!(await readAccess({ req }))) {\n throw new APIError('Forbidden', 403)\n }\n\n const requested = req.query?.range\n const range = isUmamiRange(requested) ? requested : defaultRange\n const { endAt, startAt, unit } = rangeToWindow(range)\n // The period immediately before the selected range, same length.\n const prevStartAt = startAt - (endAt - startAt)\n\n try {\n const [stats, prevStats, topPages, topCountries, series] = await Promise.all([\n client.getStats(startAt, endAt),\n client.getStats(prevStartAt, startAt),\n client.getTopPages(startAt, endAt),\n client.getTopCountries(startAt, endAt),\n client.getSeries(startAt, endAt, unit),\n ])\n\n const report: UmamiReport = { range, stats, prevStats, topPages, topCountries, series }\n return Response.json(report)\n } catch (error) {\n const status = error instanceof UmamiError ? error.status : 502\n req.payload.logger.error(\n { err: error, plugin: '@composius/payload-plugin-umami' },\n 'Failed to fetch Umami report',\n )\n return Response.json({ error: 'Failed to fetch Umami analytics' }, { status })\n }\n },\n }\n}\n","import type { Access } from 'payload'\n\nexport type UmamiAccess = {\n /**\n * Who can see the analytics. Evaluated when rendering the dashboard widget\n * (denied users get nothing) and again in the report endpoint.\n * Defaults to any authenticated user.\n */\n read?: Access\n}\n\nexport type UmamiRange = '24h' | '7d' | '30d' | '90d'\n\nexport const UMAMI_RANGES: UmamiRange[] = ['24h', '7d', '30d', '90d']\n\n/** A single `{ x, y }` point as returned by Umami metrics/pageviews. */\nexport type UmamiPoint = {\n x: string\n y: number\n}\n\n/** Response of `GET /websites/:id/stats`. */\nexport type UmamiStats = {\n pageviews: number\n visitors: number\n visits: number\n bounces: number\n totaltime: number\n}\n\n/** Response of `GET /websites/:id/pageviews`. */\nexport type UmamiSeries = {\n /** Views over time. */\n pageviews: UmamiPoint[]\n /** Visitor sessions over time. */\n sessions: UmamiPoint[]\n}\n\n/** The combined payload returned by the `/plugin-umami/report` endpoint. */\nexport type UmamiReport = {\n range: UmamiRange\n stats: UmamiStats\n /** Same stats over the period immediately before the selected range. */\n prevStats: UmamiStats\n topPages: UmamiPoint[]\n topCountries: UmamiPoint[]\n series: UmamiSeries\n}\n\n/**\n * A stat card shown in the dashboard. `duration` is the average visit\n * duration (`totaltime / visits`). The `Prev` variants show the same metric\n * over the period immediately before the selected range.\n */\nexport type UmamiStatId =\n | 'bounces'\n | 'bouncesPrev'\n | 'duration'\n | 'durationPrev'\n | 'views'\n | 'viewsPrev'\n | 'visitors'\n | 'visitorsPrev'\n | 'visits'\n | 'visitsPrev'\n\nexport const DEFAULT_STATS: UmamiStatId[] = ['visitors', 'visitorsPrev', 'views', 'viewsPrev']\n\n/** Props passed from the plugin to the dashboard client component. */\nexport type UmamiDashboardProps = {\n defaultRange?: UmamiRange\n showRangeSelector?: boolean\n stats?: UmamiStatId[]\n}\n","import type { UmamiRange } from './types.js'\n\nimport { UMAMI_RANGES } from './types.js'\n\nconst DAY = 24 * 60 * 60 * 1000\n\nconst RANGE_MS: Record<UmamiRange, number> = {\n '24h': DAY,\n '7d': 7 * DAY,\n '30d': 30 * DAY,\n '90d': 90 * DAY,\n}\n\nexport type UmamiWindow = {\n startAt: number\n endAt: number\n unit: 'hour' | 'day'\n}\n\nexport const isUmamiRange = (value: unknown): value is UmamiRange =>\n typeof value === 'string' && (UMAMI_RANGES as string[]).includes(value)\n\n/**\n * Turns a range token into the absolute `startAt`/`endAt` (ms unix) window and\n * the time-series bucket `unit` Umami expects. `24h` buckets by hour, the\n * longer ranges by day.\n */\nexport const rangeToWindow = (range: UmamiRange, now: number = Date.now()): UmamiWindow => ({\n startAt: now - RANGE_MS[range],\n endAt: now,\n unit: range === '24h' ? 'hour' : 'day',\n})\n\n/**\n * Floors a timestamp to the start of its bucket in local time. Local rather\n * than UTC so the buckets line up with the ones Umami returns (it buckets in\n * the timezone we pass) and with how the chart formats its ticks.\n */\nexport const floorToBucket = (t: number, unit: UmamiWindow['unit']): number => {\n const date = new Date(t)\n if (unit === 'hour') {\n date.setMinutes(0, 0, 0)\n } else {\n date.setHours(0, 0, 0, 0)\n }\n return date.getTime()\n}\n\n/**\n * Every bucket start in the window, so the chart can render empty periods as\n * zeroes instead of dropping them — Umami omits buckets with no traffic.\n * Steps with `Date` setters rather than fixed millisecond arithmetic so a DST\n * change doesn't shift every following bucket off the hour/midnight.\n */\nexport const bucketGrid = (range: UmamiRange, now: number = Date.now()): number[] => {\n const { endAt, startAt, unit } = rangeToWindow(range, now)\n const last = floorToBucket(endAt, unit)\n const cursor = new Date(floorToBucket(startAt, unit))\n const buckets: number[] = []\n\n while (cursor.getTime() <= last) {\n buckets.push(cursor.getTime())\n if (unit === 'hour') {\n cursor.setHours(cursor.getHours() + 1)\n } else {\n cursor.setDate(cursor.getDate() + 1)\n }\n }\n\n return buckets\n}\n","import type { UmamiPoint, UmamiSeries, UmamiStats } from '../types.js'\n\nexport type UmamiCredentials = {\n websiteId: string\n /** Umami Cloud API key (`x-umami-api-key`). Takes precedence over username/password. */\n apiKey?: string\n /** Self-hosted username (used with `password`). */\n username?: string\n /** Self-hosted password. */\n password?: string\n /** API base URL. Defaults to Umami Cloud (`https://api.umami.is`). */\n baseUrl?: string\n /** IANA timezone for time-series buckets. Defaults to the server timezone. */\n timezone?: string\n}\n\nconst CLOUD_BASE = 'https://api.umami.is'\n\n/**\n * Top-pages metric type. Umami v3 removed `url` in favor of `path`\n * (breaking change from v2). Targets v3.x.\n */\nconst TOP_PAGES_METRIC = 'path'\n\nexport class UmamiError extends Error {\n status: number\n constructor(message: string, status: number) {\n super(message)\n this.name = 'UmamiError'\n this.status = status\n }\n}\n\nexport type UmamiClient = {\n getStats: (startAt: number, endAt: number) => Promise<UmamiStats>\n getTopPages: (startAt: number, endAt: number, limit?: number) => Promise<UmamiPoint[]>\n getTopCountries: (startAt: number, endAt: number, limit?: number) => Promise<UmamiPoint[]>\n getSeries: (\n startAt: number,\n endAt: number,\n unit: 'hour' | 'day',\n ) => Promise<UmamiSeries>\n}\n\n/**\n * Server-side Umami API client. Supports both Umami Cloud (`x-umami-api-key`)\n * and self-hosted (login → cached Bearer token, re-auth once on 401).\n * Credentials never leave this module — the browser talks to the Payload\n * proxy endpoint, not to Umami.\n */\nexport const createUmamiClient = (credentials: UmamiCredentials): UmamiClient => {\n const { apiKey, baseUrl, password, timezone, username, websiteId } = credentials\n const isCloud = Boolean(apiKey)\n // Cloud data endpoints live under /v1; self-hosted under /api.\n const root = `${(baseUrl ?? CLOUD_BASE).replace(/\\/$/, '')}${isCloud ? '/v1' : '/api'}`\n const tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone\n\n let token: string | null = null\n\n const login = async (): Promise<string> => {\n const response = await fetch(`${(baseUrl ?? CLOUD_BASE).replace(/\\/$/, '')}/api/auth/login`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ password, username }),\n })\n if (!response.ok) {\n throw new UmamiError(`Umami login failed (${response.status})`, response.status)\n }\n const data = (await response.json()) as { token: string }\n token = data.token\n return token\n }\n\n const authHeader = async (): Promise<Record<string, string>> => {\n if (isCloud) {\n return { 'x-umami-api-key': apiKey as string }\n }\n if (!token) {\n await login()\n }\n return { Authorization: `Bearer ${token}` }\n }\n\n const request = async <T>(path: string, params: Record<string, string | number>): Promise<T> => {\n const query = new URLSearchParams(\n Object.entries(params).map(([key, value]) => [key, String(value)]),\n ).toString()\n const url = `${root}/websites/${websiteId}${path}?${query}`\n\n const send = async () =>\n fetch(url, { headers: { Accept: 'application/json', ...(await authHeader()) } })\n\n let response = await send()\n\n // Self-hosted Bearer tokens expire; re-authenticate once on 401.\n if (response.status === 401 && !isCloud) {\n token = null\n response = await send()\n }\n\n if (!response.ok) {\n throw new UmamiError(`Umami request failed (${response.status})`, response.status)\n }\n\n return (await response.json()) as T\n }\n\n return {\n getStats: (startAt, endAt) => request<UmamiStats>('/stats', { startAt, endAt }),\n getTopPages: (startAt, endAt, limit = 5) =>\n request<UmamiPoint[]>('/metrics', { startAt, endAt, type: TOP_PAGES_METRIC, limit }),\n getTopCountries: (startAt, endAt, limit = 5) =>\n request<UmamiPoint[]>('/metrics', { startAt, endAt, type: 'country', limit }),\n getSeries: (startAt, endAt, unit) =>\n request<UmamiSeries>('/pageviews', { startAt, endAt, unit, timezone: tz }),\n }\n}\n","export const en = {\n umami: {\n title: 'Analytics',\n timeRange: 'Time range',\n chartMetric: 'Chart stat',\n ranges: {\n '24h': 'Last 24 hours',\n '7d': 'Last 7 days',\n '30d': 'Last 30 days',\n '90d': 'Last 90 days',\n },\n stats: {\n bounces: 'Bounces',\n bouncesPrev: 'Bounces (last period)',\n duration: 'Avg. visit duration',\n durationPrev: 'Avg. visit duration (last period)',\n views: 'Views',\n viewsPrev: 'Views (last period)',\n visitors: 'Visitors',\n visitorsPrev: 'Visitors (last period)',\n visits: 'Visits',\n visitsPrev: 'Visits (last period)',\n },\n topPages: 'Top pages',\n topCountries: 'Top countries',\n messages: {\n error: 'Could not load analytics. Check the Umami configuration.',\n loading: 'Loading analytics…',\n noCountries: 'No countries in this range.',\n noPages: 'No pages in this range.',\n },\n },\n}\n","import type { Translation } from './index.js'\n\nexport const fr: Translation = {\n umami: {\n title: 'Statistiques',\n timeRange: 'Période',\n chartMetric: 'Métrique du graphique',\n ranges: {\n '24h': 'Dernières 24 heures',\n '7d': '7 derniers jours',\n '30d': '30 derniers jours',\n '90d': '90 derniers jours',\n },\n stats: {\n bounces: 'Rebonds',\n bouncesPrev: 'Rebonds (période précédente)',\n duration: 'Durée moyenne de visite',\n durationPrev: 'Durée moyenne de visite (période précédente)',\n views: 'Pages vues',\n viewsPrev: 'Pages vues (période précédente)',\n visitors: 'Visiteurs',\n visitorsPrev: 'Visiteurs (période précédente)',\n visits: 'Visites',\n visitsPrev: 'Visites (période précédente)',\n },\n topPages: 'Pages les plus vues',\n topCountries: 'Principaux pays',\n messages: {\n error: 'Impossible de charger les statistiques. Vérifiez la configuration Umami.',\n loading: 'Chargement des statistiques…',\n noCountries: 'Aucun pays sur cette période.',\n noPages: 'Aucune page sur cette période.',\n },\n },\n}\n","import type { Access, Config } from 'payload'\n\nimport type { UmamiAccess, UmamiRange, UmamiStatId } from './types.js'\n\nimport { reportEndpoint } from './endpoints/report.js'\nimport { en } from './translations/en.js'\nimport { fr } from './translations/fr.js'\nimport { DEFAULT_STATS } from './types.js'\n\nexport type { UmamiAccess, UmamiRange, UmamiReport, UmamiStatId } from './types.js'\n\n/** Default `read` access: any authenticated user. */\nexport const authenticated: Access = ({ req: { user } }) => Boolean(user)\n\nexport type ComposiusPayloadPluginUmamiConfig = {\n /**\n * Access control for the analytics, per operation. Only `read` exists for\n * now. Default: `read` requires an authenticated user.\n */\n access?: UmamiAccess\n /** Umami website ID (UUID) to report on. */\n websiteId: string\n /**\n * Umami Cloud API key (sent as `x-umami-api-key`). Use this OR\n * `username`/`password`. Takes precedence when both are set.\n */\n apiKey?: string\n /** Self-hosted username (used with `password`). */\n username?: string\n /** Self-hosted password (used with `username`). */\n password?: string\n /**\n * API base URL. Defaults to Umami Cloud (`https://api.umami.is`, data paths\n * under `/v1`). For self-hosted, set your instance URL (data paths under\n * `/api`).\n * @default 'https://api.umami.is'\n */\n baseUrl?: string\n /**\n * IANA timezone used to bucket the time-series chart.\n * @default the server timezone\n */\n timezone?: string\n /**\n * Initial time range shown in the dashboard.\n * @default '7d'\n */\n defaultRange?: UmamiRange\n /**\n * Show the in-panel range selector (24h / 7d / 30d / 90d).\n * @default true\n */\n showRangeSelector?: boolean\n /**\n * Which stat cards to show, in order. Available: `visitors`, `views`,\n * `visits`, `bounces`, `duration` (average visit duration), and their\n * `...Prev` variants for the period immediately before the selected range.\n * @default ['visitors', 'views', 'visitorsPrev', 'viewsPrev']\n */\n stats?: UmamiStatId[]\n /** Leaves the config untouched. */\n disabled?: boolean\n}\n\nconst COMPONENT_PATH = '@composius/payload-plugin-umami/rsc'\nconst COMPONENT_EXPORT = 'UmamiWidget'\nexport const WIDGET_SLUG = 'umami'\n\n/**\n * Adds an Umami web-analytics widget (stat cards, top pages, top countries,\n * and a visitors/views time chart) to the Payload admin dashboard, registered\n * through the modular-dashboard API (`admin.dashboard`, experimental in\n * Payload 3.x) so it can be dragged, resized, removed and re-added like the\n * built-in collections widget. Analytics are fetched server-side through a\n * proxy endpoint, so Umami credentials never reach the browser.\n */\nexport const ComposiusPayloadPluginUmami =\n (pluginOptions: ComposiusPayloadPluginUmamiConfig) =>\n (config: Config): Config => {\n if (pluginOptions.disabled) {\n return config\n }\n\n const { apiKey, password, username, websiteId } = pluginOptions\n const hasCredentials = Boolean(apiKey) || Boolean(username && password)\n\n // Credentials usually come from env vars, which may be absent (local dev,\n // CI). Degrade to no widgets instead of crashing the config build.\n if (!websiteId || !hasCredentials) {\n console.warn(\n '[@composius/payload-plugin-umami] `websiteId` or credentials missing, analytics widgets are disabled.',\n )\n return config\n }\n\n const defaultRange = pluginOptions.defaultRange ?? '7d'\n const readAccess = pluginOptions.access?.read ?? authenticated\n\n config.endpoints = [\n ...(config.endpoints ?? []),\n reportEndpoint(\n {\n apiKey,\n baseUrl: pluginOptions.baseUrl,\n password,\n timezone: pluginOptions.timezone,\n username,\n websiteId,\n },\n defaultRange,\n readAccess,\n ),\n ]\n\n if (!config.admin) config.admin = {}\n\n // Registered as a modular-dashboard widget so users can drag, resize,\n // remove and re-add it from the dashboard's edit mode, like the built-in\n // collections widget.\n if (!config.admin.dashboard) config.admin.dashboard = { widgets: [] }\n config.admin.dashboard.widgets.push({\n slug: WIDGET_SLUG,\n // A server component (UmamiWidget) gates rendering on `access.read`,\n // then hands the serializable props to the client dashboard.\n Component: {\n path: COMPONENT_PATH,\n exportName: COMPONENT_EXPORT,\n clientProps: {\n defaultRange,\n showRangeSelector: pluginOptions.showRangeSelector ?? true,\n stats: pluginOptions.stats ?? DEFAULT_STATS,\n },\n // serverProps stay on the server — safe to carry the access function.\n serverProps: {\n access: readAccess,\n },\n },\n label: { en: en.umami.title, fr: fr.umami.title },\n minWidth: 'medium',\n })\n\n // Show the widget by default, above the collections widget Payload adds\n // during sanitization. A user-defined layout is left untouched — the\n // widget then only appears in the \"add widget\" drawer.\n config.admin.dashboard.defaultLayout ??= [\n { widgetSlug: WIDGET_SLUG, width: 'full' },\n { widgetSlug: 'collections', width: 'full' },\n ]\n\n return config\n }\n"],"mappings":"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","names":[]}
|