@composius/payload-plugin-umami 0.3.1

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/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # @composius/payload-plugin-umami
2
+
3
+ A [Payload CMS](https://payloadcms.com) plugin that adds [Umami](https://umami.is)
4
+ web-analytics to the admin dashboard: configurable **stat cards** (default:
5
+ visitors and views, current and previous period), **top 5 pages**, **top 5
6
+ countries**, and a **visitors + views time chart**, all over a configurable
7
+ time range.
8
+
9
+ The panel is registered as a **dashboard widget** (like the built-in
10
+ collections widget): from the dashboard's edit mode it can be dragged,
11
+ resized, removed and re-added. By default it appears above the collections
12
+ widget; if the host config defines its own `admin.dashboard.defaultLayout`,
13
+ that layout is left untouched and the widget is only offered in the
14
+ "add widget" drawer.
15
+
16
+ Analytics are fetched **server-side** through a proxy endpoint
17
+ (`/api/plugin-umami/report`), so your Umami API key or password never reaches the
18
+ browser. The endpoint is admin-only (requires an authenticated Payload user).
19
+
20
+ ## Requirements
21
+
22
+ The following dependencies are required to be installed in your project before using this plugin:
23
+
24
+ - `@payloadcms/ui` (`^3.84.1`)
25
+ - `payload` (`^3.84.1`)
26
+ - `react` (`^19.0.0`)
27
+ - `recharts` (`^2.12.0 || ^3.0.0`)
28
+
29
+ ```bash
30
+ pnpm add @payloadcms/ui payload react recharts
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```ts
36
+ import { buildConfig } from 'payload'
37
+ import { ComposiusPayloadPluginUmami } from '@composius/payload-plugin-umami'
38
+
39
+ export default buildConfig({
40
+ plugins: [
41
+ // Umami Cloud
42
+ ComposiusPayloadPluginUmami({
43
+ websiteId: process.env.UMAMI_WEBSITE_ID || '',
44
+ apiKey: process.env.UMAMI_API_KEY || '',
45
+ }),
46
+ ],
47
+ // ...
48
+ })
49
+ ```
50
+
51
+ For a **self-hosted** Umami instance, pass `baseUrl` + `username`/`password`
52
+ instead of `apiKey`:
53
+
54
+ ```ts
55
+ ComposiusPayloadPluginUmami({
56
+ websiteId: process.env.UMAMI_WEBSITE_ID || '',
57
+ baseUrl: process.env.UMAMI_BASE_URL || '', // e.g. https://umami.example.com
58
+ username: process.env.UMAMI_USERNAME || '',
59
+ password: process.env.UMAMI_PASSWORD || '',
60
+ })
61
+ ```
62
+
63
+ To restrict who sees the analytics, pass an `access` object (Payload `Access`
64
+ functions, like other plugins — only `read` for now):
65
+
66
+ ```ts
67
+ ComposiusPayloadPluginUmami({
68
+ // ...
69
+ access: {
70
+ read: ({ req: { user } }) => user?.role === 'admin',
71
+ },
72
+ })
73
+ ```
74
+
75
+ Denied users don't get the dashboard widget (it renders nothing, server-side)
76
+ and the report endpoint answers 403.
77
+
78
+ When `websiteId` or credentials are missing (e.g. env vars unset in local dev or
79
+ CI), the plugin logs a warning and registers nothing instead of failing the
80
+ config build.
81
+
82
+ > Registers an admin component, so run `payload generate:importmap` after adding
83
+ > the plugin.
84
+
85
+ ## Options
86
+
87
+ | Option | Type | Notes |
88
+ | ------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------ |
89
+ | `access` | `{ read?: Access }` | who can see the analytics — checked when rendering the widget (denied users get nothing) and in the report endpoint (403). Default: any authenticated user |
90
+ | `websiteId` | `string` | required — the Umami website ID (UUID) to report on |
91
+ | `apiKey` | `string` | Umami Cloud API key (`x-umami-api-key`). Use this **or** `username`/`password`; takes precedence |
92
+ | `username` | `string` | self-hosted username (with `password`) |
93
+ | `password` | `string` | self-hosted password (with `username`) |
94
+ | `baseUrl` | `string` | API base URL, default `https://api.umami.is` (Cloud). Set your instance URL for self-hosted |
95
+ | `timezone` | `string` | IANA timezone for the time-chart buckets, default the server timezone |
96
+ | `defaultRange` | `'24h' \| '7d' \| '30d' \| '90d'` | initial range shown in the panel, default `'7d'` |
97
+ | `showRangeSelector` | `boolean` | show the in-panel range selector, default `true` |
98
+ | `stats` | `UmamiStatId[]` | stat cards to show, in order — `visitors`, `views`, `visits`, `bounces`, `duration` (avg. visit duration) and their `...Prev` variants (previous period). Default `['visitors', 'views', 'visitorsPrev', 'viewsPrev']` |
99
+ | `disabled` | `boolean` | leaves the config untouched |
100
+
101
+ The time range is both configurable via `defaultRange` and changeable live in the
102
+ panel (unless `showRangeSelector: false`); changing it refetches and updates all
103
+ widgets at once.
104
+
105
+ ## Development
106
+
107
+ From the monorepo root:
108
+
109
+ ```bash
110
+ pnpm install
111
+ pnpm generate:importmap:umami # register the dashboard component
112
+ pnpm dev:umami # dev Payload app with this plugin
113
+ pnpm vitest run packages/payload-plugin-umami/test # unit tests
114
+ pnpm vitest run dev/configs/umami # integration tests
115
+ pnpm --filter @composius/payload-plugin-umami build # build to dist/
116
+ ```
117
+
118
+ See the [root README](../../README.md) for the release flow.
@@ -0,0 +1,25 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ type UmamiRange = '24h' | '7d' | '30d' | '90d';
4
+ /**
5
+ * A stat card shown in the dashboard. `duration` is the average visit
6
+ * duration (`totaltime / visits`). The `Prev` variants show the same metric
7
+ * over the period immediately before the selected range.
8
+ */
9
+ type UmamiStatId = 'bounces' | 'bouncesPrev' | 'duration' | 'durationPrev' | 'views' | 'viewsPrev' | 'visitors' | 'visitorsPrev' | 'visits' | 'visitsPrev';
10
+ /** Props passed from the plugin to the dashboard client component. */
11
+ type UmamiDashboardProps = {
12
+ defaultRange?: UmamiRange;
13
+ showRangeSelector?: boolean;
14
+ stats?: UmamiStatId[];
15
+ };
16
+
17
+ /**
18
+ * Dashboard section that presents five Umami widgets — visits, views, top 5
19
+ * pages, top 5 countries, and a visitors/views time chart — over a shared,
20
+ * user-selectable time range. All widgets are driven by a single fetch to the
21
+ * `/plugin-umami/report` proxy endpoint, so Umami credentials stay server-side.
22
+ */
23
+ declare const UmamiDashboard: ({ defaultRange, showRangeSelector, stats, }: UmamiDashboardProps) => react_jsx_runtime.JSX.Element;
24
+
25
+ export { UmamiDashboard };
@@ -0,0 +1,396 @@
1
+ 'use client'
2
+
3
+ // src/components/UmamiDashboard.tsx
4
+ import { useConfig, useTranslation } from "@payloadcms/ui";
5
+ import { useEffect, useMemo as useMemo2, useState as useState2 } from "react";
6
+
7
+ // src/translations/en.ts
8
+ var en = {
9
+ umami: {
10
+ title: "Analytics",
11
+ timeRange: "Time range",
12
+ chartMetric: "Chart stat",
13
+ ranges: {
14
+ "24h": "Last 24 hours",
15
+ "7d": "Last 7 days",
16
+ "30d": "Last 30 days",
17
+ "90d": "Last 90 days"
18
+ },
19
+ stats: {
20
+ bounces: "Bounces",
21
+ bouncesPrev: "Bounces (last period)",
22
+ duration: "Avg. visit duration",
23
+ durationPrev: "Avg. visit duration (last period)",
24
+ views: "Views",
25
+ viewsPrev: "Views (last period)",
26
+ visitors: "Visitors",
27
+ visitorsPrev: "Visitors (last period)",
28
+ visits: "Visits",
29
+ visitsPrev: "Visits (last period)"
30
+ },
31
+ topPages: "Top pages",
32
+ topCountries: "Top countries",
33
+ messages: {
34
+ error: "Could not load analytics. Check the Umami configuration.",
35
+ loading: "Loading analytics\u2026",
36
+ noCountries: "No countries in this range.",
37
+ noPages: "No pages in this range."
38
+ }
39
+ }
40
+ };
41
+
42
+ // src/translations/fr.ts
43
+ var fr = {
44
+ umami: {
45
+ title: "Statistiques",
46
+ timeRange: "P\xE9riode",
47
+ chartMetric: "M\xE9trique du graphique",
48
+ ranges: {
49
+ "24h": "Derni\xE8res 24 heures",
50
+ "7d": "7 derniers jours",
51
+ "30d": "30 derniers jours",
52
+ "90d": "90 derniers jours"
53
+ },
54
+ stats: {
55
+ bounces: "Rebonds",
56
+ bouncesPrev: "Rebonds (p\xE9riode pr\xE9c\xE9dente)",
57
+ duration: "Dur\xE9e moyenne de visite",
58
+ durationPrev: "Dur\xE9e moyenne de visite (p\xE9riode pr\xE9c\xE9dente)",
59
+ views: "Pages vues",
60
+ viewsPrev: "Pages vues (p\xE9riode pr\xE9c\xE9dente)",
61
+ visitors: "Visiteurs",
62
+ visitorsPrev: "Visiteurs (p\xE9riode pr\xE9c\xE9dente)",
63
+ visits: "Visites",
64
+ visitsPrev: "Visites (p\xE9riode pr\xE9c\xE9dente)"
65
+ },
66
+ topPages: "Pages les plus vues",
67
+ topCountries: "Principaux pays",
68
+ messages: {
69
+ error: "Impossible de charger les statistiques. V\xE9rifiez la configuration Umami.",
70
+ loading: "Chargement des statistiques\u2026",
71
+ noCountries: "Aucun pays sur cette p\xE9riode.",
72
+ noPages: "Aucune page sur cette p\xE9riode."
73
+ }
74
+ }
75
+ };
76
+
77
+ // src/types.ts
78
+ var UMAMI_RANGES = ["24h", "7d", "30d", "90d"];
79
+ var DEFAULT_STATS = ["visitors", "views", "visitorsPrev", "viewsPrev"];
80
+
81
+ // src/components/dashboardStyles.ts
82
+ var dashboardStyles = `
83
+ .umami-dashboard {
84
+ --umami-series-views: #2a78d6;
85
+ --umami-series-visitors: #1baf7a;
86
+ --umami-grid: #e1e0d9;
87
+ --umami-axis: #c3c2b7;
88
+ --umami-muted: #898781;
89
+ container-type: inline-size;
90
+ margin-bottom: 2rem;
91
+ }
92
+ @media (prefers-color-scheme: dark) {
93
+ .umami-dashboard {
94
+ --umami-series-views: #3987e5;
95
+ --umami-series-visitors: #199e70;
96
+ --umami-grid: #2c2c2a;
97
+ --umami-axis: #383835;
98
+ }
99
+ }
100
+ html[data-theme='dark'] .umami-dashboard {
101
+ --umami-series-views: #3987e5;
102
+ --umami-series-visitors: #199e70;
103
+ --umami-grid: #2c2c2a;
104
+ --umami-axis: #383835;
105
+ }
106
+ html[data-theme='light'] .umami-dashboard {
107
+ --umami-series-views: #2a78d6;
108
+ --umami-series-visitors: #1baf7a;
109
+ --umami-grid: #e1e0d9;
110
+ --umami-axis: #c3c2b7;
111
+ }
112
+ .umami-dashboard__header {
113
+ align-items: center;
114
+ display: flex;
115
+ justify-content: space-between;
116
+ gap: 1rem;
117
+ margin-bottom: 1rem;
118
+ }
119
+ // .umami-dashboard__title { font-size: 1.15rem; font-weight: 600; margin: 0; }
120
+ .umami-dashboard__select {
121
+ background: var(--theme-elevation-50);
122
+ border: 1px solid var(--theme-elevation-150);
123
+ border-radius: 4px;
124
+ color: var(--theme-text);
125
+ padding: 0.4rem 0.6rem;
126
+ }
127
+ .umami-grid { display: grid; gap: 1rem; grid-template-columns: repeat(4, 1fr); }
128
+ .umami-card {
129
+ background: var(--theme-elevation-50);
130
+ border: 1px solid var(--theme-elevation-100);
131
+ border-radius: 6px;
132
+ padding: 1rem;
133
+ }
134
+ .umami-card__title { color: var(--theme-elevation-800); font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }
135
+ .umami-stat { display: flex; flex-direction: column; gap: 0.35rem; grid-column: span 1; }
136
+ .umami-stat__label { color: var(--theme-elevation-600); font-size: 0.85rem; }
137
+ .umami-stat__value { font-size: 2rem; font-weight: 700; line-height: 1; }
138
+ .umami-chart { grid-column: span 4; }
139
+ .umami-chart__header { display: flex; justify-content: flex-end; margin-bottom: 0.75rem; }
140
+ .umami-top { grid-column: span 2; }
141
+ .umami-top__list { list-style: none; margin: 0.75rem 0 0; padding: 0; }
142
+ .umami-top__row { align-items: baseline; display: flex; gap: 0.75rem; justify-content: space-between; padding: 0.3rem 0; }
143
+ .umami-top__name { color: var(--theme-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
144
+ .umami-top__value { color: var(--theme-elevation-800); font-variant-numeric: tabular-nums; font-weight: 600; }
145
+ .umami-empty { color: var(--theme-elevation-500); margin: 0.75rem 0 0; }
146
+ .umami-message { color: var(--theme-elevation-600); padding: 1rem 0; }
147
+ /* Responds to the widget's own width (it can be resized in the dashboard). */
148
+ @container (max-width: 900px) {
149
+ .umami-grid { grid-template-columns: repeat(2, 1fr); }
150
+ .umami-stat, .umami-top { grid-column: span 1; }
151
+ .umami-chart { grid-column: span 2; }
152
+ }
153
+ `;
154
+
155
+ // src/components/StatCard.tsx
156
+ import { jsx, jsxs } from "react/jsx-runtime";
157
+ var compact = new Intl.NumberFormat(void 0, { notation: "compact", maximumFractionDigits: 1 });
158
+ var full = new Intl.NumberFormat();
159
+ var formatDuration = (seconds) => {
160
+ const total = Math.round(seconds);
161
+ const hours = Math.floor(total / 3600);
162
+ const minutes = Math.floor(total % 3600 / 60);
163
+ const secs = total % 60;
164
+ if (hours > 0) {
165
+ return `${hours}h ${String(minutes).padStart(2, "0")}m`;
166
+ }
167
+ return `${minutes}m ${String(secs).padStart(2, "0")}s`;
168
+ };
169
+ var StatCard = ({ format = "number", label, value }) => /* @__PURE__ */ jsxs("div", { className: "umami-card umami-stat", children: [
170
+ /* @__PURE__ */ jsx("span", { className: "umami-stat__label", children: label }),
171
+ /* @__PURE__ */ jsx("span", { className: "umami-stat__value", title: format === "number" ? full.format(value) : void 0, children: format === "duration" ? formatDuration(value) : compact.format(value) })
172
+ ] });
173
+
174
+ // src/components/TopList.tsx
175
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
176
+ var full2 = new Intl.NumberFormat();
177
+ var TopList = ({ emptyLabel, items, title }) => /* @__PURE__ */ jsxs2("div", { className: "umami-card umami-top", children: [
178
+ /* @__PURE__ */ jsx2("span", { className: "umami-card__title", children: title }),
179
+ items.length === 0 ? /* @__PURE__ */ jsx2("p", { className: "umami-empty", children: emptyLabel }) : /* @__PURE__ */ jsx2("ol", { className: "umami-top__list", children: items.map((item) => /* @__PURE__ */ jsxs2("li", { className: "umami-top__row", children: [
180
+ /* @__PURE__ */ jsx2("span", { className: "umami-top__name", title: item.x, children: item.x || "\u2014" }),
181
+ /* @__PURE__ */ jsx2("span", { className: "umami-top__value", children: full2.format(item.y) })
182
+ ] }, item.x)) })
183
+ ] });
184
+
185
+ // src/components/TrafficChart.tsx
186
+ import { useMemo, useState } from "react";
187
+ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
188
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
189
+ var mergeSeries = (series) => {
190
+ const byTime = /* @__PURE__ */ new Map();
191
+ const ensure = (x) => {
192
+ const t = new Date(x).getTime();
193
+ let row = byTime.get(t);
194
+ if (!row) {
195
+ row = { t, views: 0, visitors: 0 };
196
+ byTime.set(t, row);
197
+ }
198
+ return row;
199
+ };
200
+ for (const point of series.pageviews) ensure(point.x).views = point.y;
201
+ for (const point of series.sessions) ensure(point.x).visitors = point.y;
202
+ return [...byTime.values()].sort((a, b) => a.t - b.t);
203
+ };
204
+ var TrafficChart = ({
205
+ metricLabel,
206
+ range,
207
+ series,
208
+ viewsLabel,
209
+ visitorsLabel
210
+ }) => {
211
+ const [metric, setMetric] = useState("views");
212
+ const data = useMemo(() => mergeSeries(series), [series]);
213
+ const metricLabels = {
214
+ views: viewsLabel,
215
+ visitors: visitorsLabel
216
+ };
217
+ const formatTick = (t) => {
218
+ const date = new Date(t);
219
+ return range === "24h" ? date.toLocaleTimeString(void 0, { hour: "2-digit" }) : date.toLocaleDateString(void 0, { day: "numeric", month: "short" });
220
+ };
221
+ return /* @__PURE__ */ jsxs3("div", { className: "umami-card umami-chart", children: [
222
+ /* @__PURE__ */ jsx3("div", { className: "umami-chart__header", children: /* @__PURE__ */ jsx3(
223
+ "select",
224
+ {
225
+ "aria-label": metricLabel,
226
+ className: "umami-dashboard__select",
227
+ onChange: (event) => setMetric(event.target.value),
228
+ value: metric,
229
+ children: ["views", "visitors"].map((value) => /* @__PURE__ */ jsx3("option", { value, children: metricLabels[value] }, value))
230
+ }
231
+ ) }),
232
+ /* @__PURE__ */ jsx3(ResponsiveContainer, { height: 280, width: "100%", children: /* @__PURE__ */ jsxs3(BarChart, { data, margin: { top: 8, right: 12, bottom: 0, left: -8 }, children: [
233
+ /* @__PURE__ */ jsx3(CartesianGrid, { stroke: "var(--umami-grid)", vertical: false }),
234
+ /* @__PURE__ */ jsx3(
235
+ XAxis,
236
+ {
237
+ dataKey: "t",
238
+ stroke: "var(--umami-axis)",
239
+ tick: { fill: "var(--umami-muted)", fontSize: 12 },
240
+ tickFormatter: formatTick,
241
+ tickLine: false
242
+ }
243
+ ),
244
+ /* @__PURE__ */ jsx3(
245
+ YAxis,
246
+ {
247
+ allowDecimals: false,
248
+ stroke: "var(--umami-axis)",
249
+ tick: { fill: "var(--umami-muted)", fontSize: 12 },
250
+ tickLine: false,
251
+ width: 44
252
+ }
253
+ ),
254
+ /* @__PURE__ */ jsx3(
255
+ Tooltip,
256
+ {
257
+ contentStyle: {
258
+ background: "var(--theme-elevation-50)",
259
+ border: "1px solid var(--theme-elevation-150)",
260
+ borderRadius: 4,
261
+ color: "var(--theme-text)"
262
+ },
263
+ cursor: { fill: "var(--umami-grid)", opacity: 0.5 },
264
+ labelFormatter: (t) => new Date(Number(t)).toLocaleString()
265
+ }
266
+ ),
267
+ /* @__PURE__ */ jsx3(
268
+ Bar,
269
+ {
270
+ dataKey: metric,
271
+ fill: metric === "views" ? "var(--umami-series-views)" : "var(--umami-series-visitors)",
272
+ maxBarSize: 40,
273
+ name: metricLabels[metric],
274
+ radius: [4, 4, 0, 0]
275
+ }
276
+ )
277
+ ] }) })
278
+ ] });
279
+ };
280
+
281
+ // src/components/UmamiDashboard.tsx
282
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
283
+ var avgDuration = (stats) => stats.visits > 0 ? stats.totaltime / stats.visits : 0;
284
+ var STAT_DEFS = {
285
+ visitors: { period: "current", value: (s) => s.visitors },
286
+ visitorsPrev: { period: "prev", value: (s) => s.visitors },
287
+ views: { period: "current", value: (s) => s.pageviews },
288
+ viewsPrev: { period: "prev", value: (s) => s.pageviews },
289
+ visits: { period: "current", value: (s) => s.visits },
290
+ visitsPrev: { period: "prev", value: (s) => s.visits },
291
+ bounces: { period: "current", value: (s) => s.bounces },
292
+ bouncesPrev: { period: "prev", value: (s) => s.bounces },
293
+ duration: { format: "duration", period: "current", value: avgDuration },
294
+ durationPrev: { format: "duration", period: "prev", value: avgDuration }
295
+ };
296
+ var UmamiDashboard = ({
297
+ defaultRange = "7d",
298
+ showRangeSelector = true,
299
+ stats = DEFAULT_STATS
300
+ }) => {
301
+ const { config } = useConfig();
302
+ const { i18n } = useTranslation();
303
+ const t = (i18n.language === "fr" ? fr : en).umami;
304
+ const countryName = useMemo2(() => {
305
+ const regionNames = new Intl.DisplayNames([i18n.language, "en"], { type: "region" });
306
+ return (code) => {
307
+ if (!code) return code;
308
+ try {
309
+ return regionNames.of(code.toUpperCase()) ?? code;
310
+ } catch {
311
+ return code;
312
+ }
313
+ };
314
+ }, [i18n.language]);
315
+ const [range, setRange] = useState2(defaultRange);
316
+ const [report, setReport] = useState2();
317
+ const [status, setStatus] = useState2("loading");
318
+ useEffect(() => {
319
+ const controller = new AbortController();
320
+ const load = async () => {
321
+ setStatus("loading");
322
+ try {
323
+ const response = await fetch(
324
+ `${config.serverURL}${config.routes.api}/plugin-umami/report?range=${range}`,
325
+ { credentials: "include", signal: controller.signal }
326
+ );
327
+ if (!response.ok) {
328
+ setStatus("error");
329
+ return;
330
+ }
331
+ setReport(await response.json());
332
+ setStatus("ready");
333
+ } catch (error) {
334
+ if (!controller.signal.aborted) {
335
+ console.error("Failed to load Umami analytics", error);
336
+ setStatus("error");
337
+ }
338
+ }
339
+ };
340
+ void load();
341
+ return () => controller.abort();
342
+ }, [config.serverURL, config.routes.api, range]);
343
+ return /* @__PURE__ */ jsxs4("div", { className: "umami-dashboard", children: [
344
+ /* @__PURE__ */ jsx4("style", { children: dashboardStyles }),
345
+ /* @__PURE__ */ jsxs4("div", { className: "umami-dashboard__header", children: [
346
+ /* @__PURE__ */ jsx4("h2", { className: "umami-dashboard__title", children: t.title }),
347
+ showRangeSelector && /* @__PURE__ */ jsx4(
348
+ "select",
349
+ {
350
+ "aria-label": t.timeRange,
351
+ className: "umami-dashboard__select",
352
+ onChange: (event) => setRange(event.target.value),
353
+ value: range,
354
+ children: UMAMI_RANGES.map((value) => /* @__PURE__ */ jsx4("option", { value, children: t.ranges[value] }, value))
355
+ }
356
+ )
357
+ ] }),
358
+ status === "error" ? /* @__PURE__ */ jsx4("p", { className: "umami-message", children: t.messages.error }) : status === "loading" && !report ? /* @__PURE__ */ jsx4("p", { className: "umami-message", children: t.messages.loading }) : report ? /* @__PURE__ */ jsxs4("div", { className: "umami-grid", style: { opacity: status === "loading" ? 0.6 : 1 }, children: [
359
+ stats.filter((id) => id in STAT_DEFS).map((id) => {
360
+ const def = STAT_DEFS[id];
361
+ return /* @__PURE__ */ jsx4(
362
+ StatCard,
363
+ {
364
+ format: def.format,
365
+ label: t.stats[id],
366
+ value: def.value(def.period === "prev" ? report.prevStats : report.stats)
367
+ },
368
+ id
369
+ );
370
+ }),
371
+ /* @__PURE__ */ jsx4(
372
+ TrafficChart,
373
+ {
374
+ metricLabel: t.chartMetric,
375
+ range: report.range,
376
+ series: report.series,
377
+ viewsLabel: t.stats.views,
378
+ visitorsLabel: t.stats.visitors
379
+ }
380
+ ),
381
+ /* @__PURE__ */ jsx4(TopList, { emptyLabel: t.messages.noPages, items: report.topPages, title: t.topPages }),
382
+ /* @__PURE__ */ jsx4(
383
+ TopList,
384
+ {
385
+ emptyLabel: t.messages.noCountries,
386
+ items: report.topCountries.map((point) => ({ ...point, x: countryName(point.x) })),
387
+ title: t.topCountries
388
+ }
389
+ )
390
+ ] }) : null
391
+ ] });
392
+ };
393
+ export {
394
+ UmamiDashboard
395
+ };
396
+ //# sourceMappingURL=client.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,32 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { Access, PayloadRequest } from 'payload';
3
+
4
+ type UmamiRange = '24h' | '7d' | '30d' | '90d';
5
+ /**
6
+ * A stat card shown in the dashboard. `duration` is the average visit
7
+ * duration (`totaltime / visits`). The `Prev` variants show the same metric
8
+ * over the period immediately before the selected range.
9
+ */
10
+ type UmamiStatId = 'bounces' | 'bouncesPrev' | 'duration' | 'durationPrev' | 'views' | 'viewsPrev' | 'visitors' | 'visitorsPrev' | 'visits' | 'visitsPrev';
11
+ /** Props passed from the plugin to the dashboard client component. */
12
+ type UmamiDashboardProps = {
13
+ defaultRange?: UmamiRange;
14
+ showRangeSelector?: boolean;
15
+ stats?: UmamiStatId[];
16
+ };
17
+
18
+ type UmamiWidgetProps = UmamiDashboardProps & {
19
+ /** `access.read` resolved by the plugin, evaluated per request. */
20
+ access: Access;
21
+ /** Injected by Payload when rendering dashboard widgets. */
22
+ req: PayloadRequest;
23
+ };
24
+ /**
25
+ * Server-component gate around the dashboard: evaluates the plugin's `read`
26
+ * access with the current request and renders nothing when denied, so the
27
+ * widget stays empty for unauthorized users even if it is part of their saved
28
+ * dashboard layout. The report endpoint enforces the same access server-side.
29
+ */
30
+ declare const UmamiWidget: ({ access, defaultRange, req, showRangeSelector, stats, }: UmamiWidgetProps) => Promise<react_jsx_runtime.JSX.Element | null>;
31
+
32
+ export { UmamiWidget };
@@ -0,0 +1,27 @@
1
+ // src/components/UmamiWidget.tsx
2
+ import { UmamiDashboard } from "@composius/payload-plugin-umami/client";
3
+ import { jsx } from "react/jsx-runtime";
4
+ var UmamiWidget = async ({
5
+ access,
6
+ defaultRange,
7
+ req,
8
+ showRangeSelector,
9
+ stats
10
+ }) => {
11
+ const allowed = await access({ req });
12
+ if (!allowed) {
13
+ return null;
14
+ }
15
+ return /* @__PURE__ */ jsx(
16
+ UmamiDashboard,
17
+ {
18
+ defaultRange,
19
+ showRangeSelector,
20
+ stats
21
+ }
22
+ );
23
+ };
24
+ export {
25
+ UmamiWidget
26
+ };
27
+ //# sourceMappingURL=rsc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/UmamiWidget.tsx"],"sourcesContent":["import type { Access, PayloadRequest } from 'payload'\n\n// Imported through the package's own /client subpath (not a relative path) so\n// the client bundle — and its 'use client' directive — stays external to this\n// server-component bundle.\nimport { UmamiDashboard } from '@composius/payload-plugin-umami/client'\nimport React from 'react'\n\nimport type { UmamiDashboardProps } from '../types.js'\n\nexport type UmamiWidgetProps = UmamiDashboardProps & {\n /** `access.read` resolved by the plugin, evaluated per request. */\n access: Access\n /** Injected by Payload when rendering dashboard widgets. */\n req: PayloadRequest\n}\n\n/**\n * Server-component gate around the dashboard: evaluates the plugin's `read`\n * access with the current request and renders nothing when denied, so the\n * widget stays empty for unauthorized users even if it is part of their saved\n * dashboard layout. The report endpoint enforces the same access server-side.\n */\nexport const UmamiWidget = async ({\n access,\n defaultRange,\n req,\n showRangeSelector,\n stats,\n}: UmamiWidgetProps) => {\n const allowed = await access({ req })\n\n if (!allowed) {\n return null\n }\n\n return (\n <UmamiDashboard\n defaultRange={defaultRange}\n showRangeSelector={showRangeSelector}\n stats={stats}\n />\n )\n}\n"],"mappings":";AAKA,SAAS,sBAAsB;AAgC3B;AAdG,IAAM,cAAc,OAAO;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAwB;AACtB,QAAM,UAAU,MAAM,OAAO,EAAE,IAAI,CAAC;AAEpC,MAAI,CAAC,SAAS;AACZ,WAAO;AAAA,EACT;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -0,0 +1,111 @@
1
+ import { Access, Config } from 'payload';
2
+
3
+ type UmamiAccess = {
4
+ /**
5
+ * Who can see the analytics. Evaluated when rendering the dashboard widget
6
+ * (denied users get nothing) and again in the report endpoint.
7
+ * Defaults to any authenticated user.
8
+ */
9
+ read?: Access;
10
+ };
11
+ type UmamiRange = '24h' | '7d' | '30d' | '90d';
12
+ /** A single `{ x, y }` point as returned by Umami metrics/pageviews. */
13
+ type UmamiPoint = {
14
+ x: string;
15
+ y: number;
16
+ };
17
+ /** Response of `GET /websites/:id/stats`. */
18
+ type UmamiStats = {
19
+ pageviews: number;
20
+ visitors: number;
21
+ visits: number;
22
+ bounces: number;
23
+ totaltime: number;
24
+ };
25
+ /** Response of `GET /websites/:id/pageviews`. */
26
+ type UmamiSeries = {
27
+ /** Views over time. */
28
+ pageviews: UmamiPoint[];
29
+ /** Visitor sessions over time. */
30
+ sessions: UmamiPoint[];
31
+ };
32
+ /** The combined payload returned by the `/plugin-umami/report` endpoint. */
33
+ type UmamiReport = {
34
+ range: UmamiRange;
35
+ stats: UmamiStats;
36
+ /** Same stats over the period immediately before the selected range. */
37
+ prevStats: UmamiStats;
38
+ topPages: UmamiPoint[];
39
+ topCountries: UmamiPoint[];
40
+ series: UmamiSeries;
41
+ };
42
+ /**
43
+ * A stat card shown in the dashboard. `duration` is the average visit
44
+ * duration (`totaltime / visits`). The `Prev` variants show the same metric
45
+ * over the period immediately before the selected range.
46
+ */
47
+ type UmamiStatId = 'bounces' | 'bouncesPrev' | 'duration' | 'durationPrev' | 'views' | 'viewsPrev' | 'visitors' | 'visitorsPrev' | 'visits' | 'visitsPrev';
48
+
49
+ /** Default `read` access: any authenticated user. */
50
+ declare const authenticated: Access;
51
+ type ComposiusPayloadPluginUmamiConfig = {
52
+ /**
53
+ * Access control for the analytics, per operation. Only `read` exists for
54
+ * now. Default: `read` requires an authenticated user.
55
+ */
56
+ access?: UmamiAccess;
57
+ /** Umami website ID (UUID) to report on. */
58
+ websiteId: string;
59
+ /**
60
+ * Umami Cloud API key (sent as `x-umami-api-key`). Use this OR
61
+ * `username`/`password`. Takes precedence when both are set.
62
+ */
63
+ apiKey?: string;
64
+ /** Self-hosted username (used with `password`). */
65
+ username?: string;
66
+ /** Self-hosted password (used with `username`). */
67
+ password?: string;
68
+ /**
69
+ * API base URL. Defaults to Umami Cloud (`https://api.umami.is`, data paths
70
+ * under `/v1`). For self-hosted, set your instance URL (data paths under
71
+ * `/api`).
72
+ * @default 'https://api.umami.is'
73
+ */
74
+ baseUrl?: string;
75
+ /**
76
+ * IANA timezone used to bucket the time-series chart.
77
+ * @default the server timezone
78
+ */
79
+ timezone?: string;
80
+ /**
81
+ * Initial time range shown in the dashboard.
82
+ * @default '7d'
83
+ */
84
+ defaultRange?: UmamiRange;
85
+ /**
86
+ * Show the in-panel range selector (24h / 7d / 30d / 90d).
87
+ * @default true
88
+ */
89
+ showRangeSelector?: boolean;
90
+ /**
91
+ * Which stat cards to show, in order. Available: `visitors`, `views`,
92
+ * `visits`, `bounces`, `duration` (average visit duration), and their
93
+ * `...Prev` variants for the period immediately before the selected range.
94
+ * @default ['visitors', 'views', 'visitorsPrev', 'viewsPrev']
95
+ */
96
+ stats?: UmamiStatId[];
97
+ /** Leaves the config untouched. */
98
+ disabled?: boolean;
99
+ };
100
+ declare const WIDGET_SLUG = "umami";
101
+ /**
102
+ * Adds an Umami web-analytics widget (stat cards, top pages, top countries,
103
+ * and a visitors/views time chart) to the Payload admin dashboard, registered
104
+ * through the modular-dashboard API (`admin.dashboard`, experimental in
105
+ * Payload 3.x) so it can be dragged, resized, removed and re-added like the
106
+ * built-in collections widget. Analytics are fetched server-side through a
107
+ * proxy endpoint, so Umami credentials never reach the browser.
108
+ */
109
+ declare const ComposiusPayloadPluginUmami: (pluginOptions: ComposiusPayloadPluginUmamiConfig) => (config: Config) => Config;
110
+
111
+ export { ComposiusPayloadPluginUmami, type ComposiusPayloadPluginUmamiConfig, type UmamiAccess, type UmamiRange, type UmamiReport, type UmamiStatId, WIDGET_SLUG, authenticated };
package/dist/index.js ADDED
@@ -0,0 +1,263 @@
1
+ // src/endpoints/report.ts
2
+ import { APIError } from "payload";
3
+
4
+ // src/types.ts
5
+ var UMAMI_RANGES = ["24h", "7d", "30d", "90d"];
6
+ var DEFAULT_STATS = ["visitors", "views", "visitorsPrev", "viewsPrev"];
7
+
8
+ // src/range.ts
9
+ var DAY = 24 * 60 * 60 * 1e3;
10
+ var RANGE_MS = {
11
+ "24h": DAY,
12
+ "7d": 7 * DAY,
13
+ "30d": 30 * DAY,
14
+ "90d": 90 * DAY
15
+ };
16
+ var isUmamiRange = (value) => typeof value === "string" && UMAMI_RANGES.includes(value);
17
+ var rangeToWindow = (range, now = Date.now()) => ({
18
+ startAt: now - RANGE_MS[range],
19
+ endAt: now,
20
+ unit: range === "24h" ? "hour" : "day"
21
+ });
22
+
23
+ // src/umami/client.ts
24
+ var CLOUD_BASE = "https://api.umami.is";
25
+ var TOP_PAGES_METRIC = "path";
26
+ var UmamiError = class extends Error {
27
+ status;
28
+ constructor(message, status) {
29
+ super(message);
30
+ this.name = "UmamiError";
31
+ this.status = status;
32
+ }
33
+ };
34
+ var createUmamiClient = (credentials) => {
35
+ const { apiKey, baseUrl, password, timezone, username, websiteId } = credentials;
36
+ const isCloud = Boolean(apiKey);
37
+ const root = `${(baseUrl ?? CLOUD_BASE).replace(/\/$/, "")}${isCloud ? "/v1" : "/api"}`;
38
+ const tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
39
+ let token = null;
40
+ const login = async () => {
41
+ const response = await fetch(`${(baseUrl ?? CLOUD_BASE).replace(/\/$/, "")}/api/auth/login`, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ password, username })
45
+ });
46
+ if (!response.ok) {
47
+ throw new UmamiError(`Umami login failed (${response.status})`, response.status);
48
+ }
49
+ const data = await response.json();
50
+ token = data.token;
51
+ return token;
52
+ };
53
+ const authHeader = async () => {
54
+ if (isCloud) {
55
+ return { "x-umami-api-key": apiKey };
56
+ }
57
+ if (!token) {
58
+ await login();
59
+ }
60
+ return { Authorization: `Bearer ${token}` };
61
+ };
62
+ const request = async (path, params) => {
63
+ const query = new URLSearchParams(
64
+ Object.entries(params).map(([key, value]) => [key, String(value)])
65
+ ).toString();
66
+ const url = `${root}/websites/${websiteId}${path}?${query}`;
67
+ const send = async () => fetch(url, { headers: { Accept: "application/json", ...await authHeader() } });
68
+ let response = await send();
69
+ if (response.status === 401 && !isCloud) {
70
+ token = null;
71
+ response = await send();
72
+ }
73
+ if (!response.ok) {
74
+ throw new UmamiError(`Umami request failed (${response.status})`, response.status);
75
+ }
76
+ return await response.json();
77
+ };
78
+ return {
79
+ getStats: (startAt, endAt) => request("/stats", { startAt, endAt }),
80
+ getTopPages: (startAt, endAt, limit = 5) => request("/metrics", { startAt, endAt, type: TOP_PAGES_METRIC, limit }),
81
+ getTopCountries: (startAt, endAt, limit = 5) => request("/metrics", { startAt, endAt, type: "country", limit }),
82
+ getSeries: (startAt, endAt, unit) => request("/pageviews", { startAt, endAt, unit, timezone: tz })
83
+ };
84
+ };
85
+
86
+ // src/endpoints/report.ts
87
+ var REPORT_PATH = "/plugin-umami/report";
88
+ var reportEndpoint = (credentials, defaultRange, readAccess) => {
89
+ const client = createUmamiClient(credentials);
90
+ return {
91
+ path: REPORT_PATH,
92
+ method: "get",
93
+ handler: async (req) => {
94
+ if (!req.user) {
95
+ throw new APIError("Unauthorized", 401);
96
+ }
97
+ if (!await readAccess({ req })) {
98
+ throw new APIError("Forbidden", 403);
99
+ }
100
+ const requested = req.query?.range;
101
+ const range = isUmamiRange(requested) ? requested : defaultRange;
102
+ const { endAt, startAt, unit } = rangeToWindow(range);
103
+ const prevStartAt = startAt - (endAt - startAt);
104
+ try {
105
+ const [stats, prevStats, topPages, topCountries, series] = await Promise.all([
106
+ client.getStats(startAt, endAt),
107
+ client.getStats(prevStartAt, startAt),
108
+ client.getTopPages(startAt, endAt),
109
+ client.getTopCountries(startAt, endAt),
110
+ client.getSeries(startAt, endAt, unit)
111
+ ]);
112
+ const report = { range, stats, prevStats, topPages, topCountries, series };
113
+ return Response.json(report);
114
+ } catch (error) {
115
+ const status = error instanceof UmamiError ? error.status : 502;
116
+ req.payload.logger.error(
117
+ { err: error, plugin: "@composius/payload-plugin-umami" },
118
+ "Failed to fetch Umami report"
119
+ );
120
+ return Response.json({ error: "Failed to fetch Umami analytics" }, { status });
121
+ }
122
+ }
123
+ };
124
+ };
125
+
126
+ // src/translations/en.ts
127
+ var en = {
128
+ umami: {
129
+ title: "Analytics",
130
+ timeRange: "Time range",
131
+ chartMetric: "Chart stat",
132
+ ranges: {
133
+ "24h": "Last 24 hours",
134
+ "7d": "Last 7 days",
135
+ "30d": "Last 30 days",
136
+ "90d": "Last 90 days"
137
+ },
138
+ stats: {
139
+ bounces: "Bounces",
140
+ bouncesPrev: "Bounces (last period)",
141
+ duration: "Avg. visit duration",
142
+ durationPrev: "Avg. visit duration (last period)",
143
+ views: "Views",
144
+ viewsPrev: "Views (last period)",
145
+ visitors: "Visitors",
146
+ visitorsPrev: "Visitors (last period)",
147
+ visits: "Visits",
148
+ visitsPrev: "Visits (last period)"
149
+ },
150
+ topPages: "Top pages",
151
+ topCountries: "Top countries",
152
+ messages: {
153
+ error: "Could not load analytics. Check the Umami configuration.",
154
+ loading: "Loading analytics\u2026",
155
+ noCountries: "No countries in this range.",
156
+ noPages: "No pages in this range."
157
+ }
158
+ }
159
+ };
160
+
161
+ // src/translations/fr.ts
162
+ var fr = {
163
+ umami: {
164
+ title: "Statistiques",
165
+ timeRange: "P\xE9riode",
166
+ chartMetric: "M\xE9trique du graphique",
167
+ ranges: {
168
+ "24h": "Derni\xE8res 24 heures",
169
+ "7d": "7 derniers jours",
170
+ "30d": "30 derniers jours",
171
+ "90d": "90 derniers jours"
172
+ },
173
+ stats: {
174
+ bounces: "Rebonds",
175
+ bouncesPrev: "Rebonds (p\xE9riode pr\xE9c\xE9dente)",
176
+ duration: "Dur\xE9e moyenne de visite",
177
+ durationPrev: "Dur\xE9e moyenne de visite (p\xE9riode pr\xE9c\xE9dente)",
178
+ views: "Pages vues",
179
+ viewsPrev: "Pages vues (p\xE9riode pr\xE9c\xE9dente)",
180
+ visitors: "Visiteurs",
181
+ visitorsPrev: "Visiteurs (p\xE9riode pr\xE9c\xE9dente)",
182
+ visits: "Visites",
183
+ visitsPrev: "Visites (p\xE9riode pr\xE9c\xE9dente)"
184
+ },
185
+ topPages: "Pages les plus vues",
186
+ topCountries: "Principaux pays",
187
+ messages: {
188
+ error: "Impossible de charger les statistiques. V\xE9rifiez la configuration Umami.",
189
+ loading: "Chargement des statistiques\u2026",
190
+ noCountries: "Aucun pays sur cette p\xE9riode.",
191
+ noPages: "Aucune page sur cette p\xE9riode."
192
+ }
193
+ }
194
+ };
195
+
196
+ // src/index.ts
197
+ var authenticated = ({ req: { user } }) => Boolean(user);
198
+ var COMPONENT_PATH = "@composius/payload-plugin-umami/rsc";
199
+ var COMPONENT_EXPORT = "UmamiWidget";
200
+ var WIDGET_SLUG = "umami";
201
+ var ComposiusPayloadPluginUmami = (pluginOptions) => (config) => {
202
+ if (pluginOptions.disabled) {
203
+ return config;
204
+ }
205
+ const { apiKey, password, username, websiteId } = pluginOptions;
206
+ const hasCredentials = Boolean(apiKey) || Boolean(username && password);
207
+ if (!websiteId || !hasCredentials) {
208
+ console.warn(
209
+ "[@composius/payload-plugin-umami] `websiteId` or credentials missing, analytics widgets are disabled."
210
+ );
211
+ return config;
212
+ }
213
+ const defaultRange = pluginOptions.defaultRange ?? "7d";
214
+ const readAccess = pluginOptions.access?.read ?? authenticated;
215
+ config.endpoints = [
216
+ ...config.endpoints ?? [],
217
+ reportEndpoint(
218
+ {
219
+ apiKey,
220
+ baseUrl: pluginOptions.baseUrl,
221
+ password,
222
+ timezone: pluginOptions.timezone,
223
+ username,
224
+ websiteId
225
+ },
226
+ defaultRange,
227
+ readAccess
228
+ )
229
+ ];
230
+ if (!config.admin) config.admin = {};
231
+ if (!config.admin.dashboard) config.admin.dashboard = { widgets: [] };
232
+ config.admin.dashboard.widgets.push({
233
+ slug: WIDGET_SLUG,
234
+ // A server component (UmamiWidget) gates rendering on `access.read`,
235
+ // then hands the serializable props to the client dashboard.
236
+ Component: {
237
+ path: COMPONENT_PATH,
238
+ exportName: COMPONENT_EXPORT,
239
+ clientProps: {
240
+ defaultRange,
241
+ showRangeSelector: pluginOptions.showRangeSelector ?? true,
242
+ stats: pluginOptions.stats ?? DEFAULT_STATS
243
+ },
244
+ // serverProps stay on the server — safe to carry the access function.
245
+ serverProps: {
246
+ access: readAccess
247
+ }
248
+ },
249
+ label: { en: en.umami.title, fr: fr.umami.title },
250
+ minWidth: "medium"
251
+ });
252
+ config.admin.dashboard.defaultLayout ??= [
253
+ { widgetSlug: WIDGET_SLUG, width: "full" },
254
+ { widgetSlug: "collections", width: "full" }
255
+ ];
256
+ return config;
257
+ };
258
+ export {
259
+ ComposiusPayloadPluginUmami,
260
+ WIDGET_SLUG,
261
+ authenticated
262
+ };
263
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@composius/payload-plugin-umami",
3
+ "version": "0.3.1",
4
+ "description": "Payload plugin that adds Umami web-analytics widgets to the admin dashboard",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Composius"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/Composius/payload-plugins",
12
+ "directory": "packages/payload-plugin-umami"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "import": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./client": {
22
+ "import": "./dist/exports/client.js",
23
+ "types": "./dist/exports/client.d.ts",
24
+ "default": "./dist/exports/client.js"
25
+ },
26
+ "./rsc": {
27
+ "import": "./dist/exports/rsc.js",
28
+ "types": "./dist/exports/rsc.d.ts",
29
+ "default": "./dist/exports/rsc.js"
30
+ }
31
+ },
32
+ "main": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "dependencies": {
38
+ "recharts": "^3.9.2"
39
+ },
40
+ "devDependencies": {
41
+ "@payloadcms/ui": "3.84.1",
42
+ "@types/react": "19.2.14",
43
+ "payload": "3.84.1",
44
+ "react": "19.2.6",
45
+ "recharts": "3.9.2",
46
+ "rimraf": "3.0.2",
47
+ "tsup": "8.5.0",
48
+ "typescript": "5.7.3"
49
+ },
50
+ "peerDependencies": {
51
+ "@payloadcms/ui": "^3.84.1",
52
+ "payload": "^3.84.1",
53
+ "react": "^19.0.0",
54
+ "recharts": "^2.12.0 || ^3.0.0"
55
+ },
56
+ "engines": {
57
+ "node": "^18.20.2 || >=20.9.0",
58
+ "pnpm": "^9 || ^10 || ^11"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public",
62
+ "registry": "https://registry.npmjs.org/"
63
+ },
64
+ "scripts": {
65
+ "build": "tsup",
66
+ "clean": "rimraf {dist,*.tsbuildinfo}"
67
+ }
68
+ }