@momentumcms/plugins-analytics 0.2.0 → 0.4.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/index.cjs +9 -3
- package/index.js +9 -3
- package/lib/analytics-admin-routes.cjs +2043 -0
- package/lib/analytics-admin-routes.js +2130 -0
- package/lib/client/tracker.cjs +541 -0
- package/lib/client/tracker.js +523 -0
- package/lib/collectors/block-field-injector.cjs +206 -0
- package/lib/collectors/block-field-injector.js +179 -0
- package/package.json +60 -55
- package/src/lib/analytics-admin-routes.d.ts +3 -0
- package/CHANGELOG.md +0 -88
- package/LICENSE +0 -21
|
@@ -0,0 +1,2130 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
12
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
13
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
14
|
+
if (decorator = decorators[i])
|
|
15
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
16
|
+
if (kind && result)
|
|
17
|
+
__defProp(target, key, result);
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// libs/plugins/analytics/src/lib/dashboard/analytics.service.ts
|
|
22
|
+
import { Injectable, signal } from "@angular/core";
|
|
23
|
+
var AnalyticsService;
|
|
24
|
+
var init_analytics_service = __esm({
|
|
25
|
+
"libs/plugins/analytics/src/lib/dashboard/analytics.service.ts"() {
|
|
26
|
+
"use strict";
|
|
27
|
+
AnalyticsService = class {
|
|
28
|
+
constructor() {
|
|
29
|
+
/** Loading state */
|
|
30
|
+
this.loading = signal(false);
|
|
31
|
+
/** Error state */
|
|
32
|
+
this.error = signal(null);
|
|
33
|
+
/** Summary data */
|
|
34
|
+
this.summary = signal(null);
|
|
35
|
+
/** Events query result */
|
|
36
|
+
this.events = signal(null);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fetch the analytics summary.
|
|
40
|
+
*/
|
|
41
|
+
async fetchSummary(params = {}) {
|
|
42
|
+
this.loading.set(true);
|
|
43
|
+
this.error.set(null);
|
|
44
|
+
try {
|
|
45
|
+
const searchParams = new URLSearchParams();
|
|
46
|
+
if (params.from)
|
|
47
|
+
searchParams.set("from", params.from);
|
|
48
|
+
if (params.to)
|
|
49
|
+
searchParams.set("to", params.to);
|
|
50
|
+
const query = searchParams.toString();
|
|
51
|
+
const url = query ? `/api/analytics/summary?${query}` : "/api/analytics/summary";
|
|
52
|
+
const response = await fetch(url);
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
55
|
+
}
|
|
56
|
+
const data = await response.json();
|
|
57
|
+
this.summary.set(data);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
60
|
+
this.error.set(message);
|
|
61
|
+
} finally {
|
|
62
|
+
this.loading.set(false);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Query analytics events.
|
|
67
|
+
*/
|
|
68
|
+
async queryEvents(params = {}) {
|
|
69
|
+
this.loading.set(true);
|
|
70
|
+
this.error.set(null);
|
|
71
|
+
try {
|
|
72
|
+
const searchParams = new URLSearchParams();
|
|
73
|
+
if (params.category)
|
|
74
|
+
searchParams.set("category", params.category);
|
|
75
|
+
if (params.name)
|
|
76
|
+
searchParams.set("name", params.name);
|
|
77
|
+
if (params.collection)
|
|
78
|
+
searchParams.set("collection", params.collection);
|
|
79
|
+
if (params.search)
|
|
80
|
+
searchParams.set("search", params.search);
|
|
81
|
+
if (params.from)
|
|
82
|
+
searchParams.set("from", params.from);
|
|
83
|
+
if (params.to)
|
|
84
|
+
searchParams.set("to", params.to);
|
|
85
|
+
if (params.limit)
|
|
86
|
+
searchParams.set("limit", String(params.limit));
|
|
87
|
+
if (params.page)
|
|
88
|
+
searchParams.set("page", String(params.page));
|
|
89
|
+
const query = searchParams.toString();
|
|
90
|
+
const url = query ? `/api/analytics/query?${query}` : "/api/analytics/query";
|
|
91
|
+
const response = await fetch(url);
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
94
|
+
}
|
|
95
|
+
const data = await response.json();
|
|
96
|
+
this.events.set(data);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
|
+
this.error.set(message);
|
|
100
|
+
} finally {
|
|
101
|
+
this.loading.set(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
AnalyticsService = __decorateClass([
|
|
106
|
+
Injectable({ providedIn: "root" })
|
|
107
|
+
], AnalyticsService);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// libs/plugins/analytics/src/lib/utils/type-guards.ts
|
|
112
|
+
function isRecord(val) {
|
|
113
|
+
return val != null && typeof val === "object" && !Array.isArray(val);
|
|
114
|
+
}
|
|
115
|
+
var init_type_guards = __esm({
|
|
116
|
+
"libs/plugins/analytics/src/lib/utils/type-guards.ts"() {
|
|
117
|
+
"use strict";
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// libs/plugins/analytics/src/lib/dashboard/widgets/block-analytics-widget.component.ts
|
|
122
|
+
import {
|
|
123
|
+
Component,
|
|
124
|
+
ChangeDetectionStrategy,
|
|
125
|
+
signal as signal2,
|
|
126
|
+
computed,
|
|
127
|
+
inject,
|
|
128
|
+
PLATFORM_ID
|
|
129
|
+
} from "@angular/core";
|
|
130
|
+
import { isPlatformBrowser } from "@angular/common";
|
|
131
|
+
import { Card, CardHeader, CardTitle, CardContent, Badge, Skeleton } from "@momentumcms/ui";
|
|
132
|
+
import { NgIcon, provideIcons } from "@ng-icons/core";
|
|
133
|
+
import { heroCubeTransparent } from "@ng-icons/heroicons/outline";
|
|
134
|
+
function parseEventEntries(data) {
|
|
135
|
+
if (!isRecord(data) || !Array.isArray(data["events"]))
|
|
136
|
+
return [];
|
|
137
|
+
const result = [];
|
|
138
|
+
for (const item of data["events"]) {
|
|
139
|
+
if (isRecord(item) && isRecord(item["properties"])) {
|
|
140
|
+
result.push({ properties: item["properties"] });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
var BlockAnalyticsWidgetComponent;
|
|
146
|
+
var init_block_analytics_widget_component = __esm({
|
|
147
|
+
"libs/plugins/analytics/src/lib/dashboard/widgets/block-analytics-widget.component.ts"() {
|
|
148
|
+
"use strict";
|
|
149
|
+
init_type_guards();
|
|
150
|
+
BlockAnalyticsWidgetComponent = class {
|
|
151
|
+
constructor() {
|
|
152
|
+
this.platformId = inject(PLATFORM_ID);
|
|
153
|
+
this.loading = signal2(false);
|
|
154
|
+
this.metrics = signal2([]);
|
|
155
|
+
this.maxCount = computed(() => {
|
|
156
|
+
const all = this.metrics();
|
|
157
|
+
if (all.length === 0)
|
|
158
|
+
return 1;
|
|
159
|
+
return Math.max(...all.map((m) => m.impressions), 1);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
ngOnInit() {
|
|
163
|
+
if (!isPlatformBrowser(this.platformId))
|
|
164
|
+
return;
|
|
165
|
+
void this.fetchBlockMetrics();
|
|
166
|
+
}
|
|
167
|
+
barWidth(count) {
|
|
168
|
+
return Math.max(2, count / this.maxCount() * 100);
|
|
169
|
+
}
|
|
170
|
+
async fetchBlockMetrics() {
|
|
171
|
+
this.loading.set(true);
|
|
172
|
+
try {
|
|
173
|
+
const [impressionRes, hoverRes] = await Promise.all([
|
|
174
|
+
fetch("/api/analytics/query?name=block_impression&limit=500"),
|
|
175
|
+
fetch("/api/analytics/query?name=block_hover&limit=500")
|
|
176
|
+
]);
|
|
177
|
+
const impressionEvents = impressionRes.ok ? parseEventEntries(await impressionRes.json()) : [];
|
|
178
|
+
const hoverEvents = hoverRes.ok ? parseEventEntries(await hoverRes.json()) : [];
|
|
179
|
+
const impressionMap = /* @__PURE__ */ new Map();
|
|
180
|
+
for (const event of impressionEvents) {
|
|
181
|
+
const bt = String(event.properties["blockType"] ?? "unknown");
|
|
182
|
+
impressionMap.set(bt, (impressionMap.get(bt) ?? 0) + 1);
|
|
183
|
+
}
|
|
184
|
+
const hoverMap = /* @__PURE__ */ new Map();
|
|
185
|
+
for (const event of hoverEvents) {
|
|
186
|
+
const bt = String(event.properties["blockType"] ?? "unknown");
|
|
187
|
+
hoverMap.set(bt, (hoverMap.get(bt) ?? 0) + 1);
|
|
188
|
+
}
|
|
189
|
+
const allTypes = /* @__PURE__ */ new Set([...impressionMap.keys(), ...hoverMap.keys()]);
|
|
190
|
+
const result = [];
|
|
191
|
+
for (const blockType of allTypes) {
|
|
192
|
+
const impressions = impressionMap.get(blockType) ?? 0;
|
|
193
|
+
const hovers = hoverMap.get(blockType) ?? 0;
|
|
194
|
+
const hoverRate = impressions > 0 ? hovers / impressions * 100 : 0;
|
|
195
|
+
result.push({ blockType, impressions, hovers, hoverRate });
|
|
196
|
+
}
|
|
197
|
+
result.sort((a, b) => b.impressions - a.impressions);
|
|
198
|
+
this.metrics.set(result);
|
|
199
|
+
} catch {
|
|
200
|
+
} finally {
|
|
201
|
+
this.loading.set(false);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
BlockAnalyticsWidgetComponent = __decorateClass([
|
|
206
|
+
Component({
|
|
207
|
+
selector: "mcms-block-analytics-widget",
|
|
208
|
+
imports: [Card, CardHeader, CardTitle, CardContent, Badge, Skeleton, NgIcon],
|
|
209
|
+
providers: [provideIcons({ heroCubeTransparent })],
|
|
210
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
211
|
+
host: { class: "block" },
|
|
212
|
+
template: `
|
|
213
|
+
<mcms-card>
|
|
214
|
+
<mcms-card-header>
|
|
215
|
+
<div class="flex items-center gap-2">
|
|
216
|
+
<ng-icon
|
|
217
|
+
name="heroCubeTransparent"
|
|
218
|
+
class="text-muted-foreground"
|
|
219
|
+
size="18"
|
|
220
|
+
aria-hidden="true"
|
|
221
|
+
/>
|
|
222
|
+
<mcms-card-title>Block Engagement</mcms-card-title>
|
|
223
|
+
</div>
|
|
224
|
+
</mcms-card-header>
|
|
225
|
+
<mcms-card-content>
|
|
226
|
+
@if (loading() && metrics().length === 0) {
|
|
227
|
+
<div class="space-y-3">
|
|
228
|
+
@for (i of [1, 2, 3]; track i) {
|
|
229
|
+
<mcms-skeleton class="h-8 w-full" />
|
|
230
|
+
}
|
|
231
|
+
</div>
|
|
232
|
+
} @else if (metrics().length > 0) {
|
|
233
|
+
<div class="space-y-3">
|
|
234
|
+
@for (m of metrics(); track m.blockType) {
|
|
235
|
+
<div class="flex items-center justify-between gap-4">
|
|
236
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
237
|
+
<mcms-badge variant="outline">{{ m.blockType }}</mcms-badge>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
|
240
|
+
<span title="Impressions">{{ m.impressions }} views</span>
|
|
241
|
+
<span title="Hovers">{{ m.hovers }} hovers</span>
|
|
242
|
+
<mcms-badge [variant]="m.hoverRate > 5 ? 'success' : 'secondary'">
|
|
243
|
+
{{ m.hoverRate.toFixed(1) }}% hover
|
|
244
|
+
</mcms-badge>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
<!-- Bar visualization -->
|
|
248
|
+
<div
|
|
249
|
+
class="flex gap-1 h-2"
|
|
250
|
+
role="group"
|
|
251
|
+
[attr.aria-label]="m.blockType + ' engagement bars'"
|
|
252
|
+
>
|
|
253
|
+
<div
|
|
254
|
+
class="bg-primary/60 rounded-full"
|
|
255
|
+
role="meter"
|
|
256
|
+
[attr.aria-valuenow]="m.impressions"
|
|
257
|
+
[attr.aria-valuemin]="0"
|
|
258
|
+
[attr.aria-label]="'Impressions: ' + m.impressions"
|
|
259
|
+
[style.width.%]="barWidth(m.impressions)"
|
|
260
|
+
></div>
|
|
261
|
+
<div
|
|
262
|
+
class="bg-primary rounded-full"
|
|
263
|
+
role="meter"
|
|
264
|
+
[attr.aria-valuenow]="m.hovers"
|
|
265
|
+
[attr.aria-valuemin]="0"
|
|
266
|
+
[attr.aria-label]="'Hovers: ' + m.hovers"
|
|
267
|
+
[style.width.%]="barWidth(m.hovers)"
|
|
268
|
+
></div>
|
|
269
|
+
</div>
|
|
270
|
+
}
|
|
271
|
+
</div>
|
|
272
|
+
} @else {
|
|
273
|
+
<p class="text-sm text-muted-foreground text-center py-4">
|
|
274
|
+
No block tracking data yet. Enable tracking on blocks to see engagement.
|
|
275
|
+
</p>
|
|
276
|
+
}
|
|
277
|
+
</mcms-card-content>
|
|
278
|
+
</mcms-card>
|
|
279
|
+
`
|
|
280
|
+
})
|
|
281
|
+
], BlockAnalyticsWidgetComponent);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// libs/plugins/analytics/src/lib/dashboard/analytics-dashboard.page.ts
|
|
286
|
+
var analytics_dashboard_page_exports = {};
|
|
287
|
+
__export(analytics_dashboard_page_exports, {
|
|
288
|
+
AnalyticsDashboardPage: () => AnalyticsDashboardPage
|
|
289
|
+
});
|
|
290
|
+
import {
|
|
291
|
+
Component as Component2,
|
|
292
|
+
ChangeDetectionStrategy as ChangeDetectionStrategy2,
|
|
293
|
+
inject as inject2,
|
|
294
|
+
computed as computed2,
|
|
295
|
+
signal as signal3,
|
|
296
|
+
PLATFORM_ID as PLATFORM_ID2
|
|
297
|
+
} from "@angular/core";
|
|
298
|
+
import { isPlatformBrowser as isPlatformBrowser2 } from "@angular/common";
|
|
299
|
+
import {
|
|
300
|
+
Card as Card2,
|
|
301
|
+
CardHeader as CardHeader2,
|
|
302
|
+
CardTitle as CardTitle2,
|
|
303
|
+
CardDescription,
|
|
304
|
+
CardContent as CardContent2,
|
|
305
|
+
Badge as Badge2,
|
|
306
|
+
Skeleton as Skeleton2
|
|
307
|
+
} from "@momentumcms/ui";
|
|
308
|
+
import { NgIcon as NgIcon2, provideIcons as provideIcons2 } from "@ng-icons/core";
|
|
309
|
+
import {
|
|
310
|
+
heroChartBarSquare,
|
|
311
|
+
heroArrowTrendingUp,
|
|
312
|
+
heroClock,
|
|
313
|
+
heroUsers,
|
|
314
|
+
heroDocumentText,
|
|
315
|
+
heroArrowPath,
|
|
316
|
+
heroMagnifyingGlass,
|
|
317
|
+
heroDevicePhoneMobile,
|
|
318
|
+
heroGlobeAlt,
|
|
319
|
+
heroChevronLeft,
|
|
320
|
+
heroChevronRight
|
|
321
|
+
} from "@ng-icons/heroicons/outline";
|
|
322
|
+
var AnalyticsDashboardPage;
|
|
323
|
+
var init_analytics_dashboard_page = __esm({
|
|
324
|
+
"libs/plugins/analytics/src/lib/dashboard/analytics-dashboard.page.ts"() {
|
|
325
|
+
"use strict";
|
|
326
|
+
init_analytics_service();
|
|
327
|
+
init_block_analytics_widget_component();
|
|
328
|
+
AnalyticsDashboardPage = class {
|
|
329
|
+
constructor() {
|
|
330
|
+
this.analytics = inject2(AnalyticsService);
|
|
331
|
+
this.platformId = inject2(PLATFORM_ID2);
|
|
332
|
+
/** Date range options */
|
|
333
|
+
this.dateRanges = [
|
|
334
|
+
{
|
|
335
|
+
label: "24h",
|
|
336
|
+
value: "24h",
|
|
337
|
+
getFrom: () => new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString()
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
label: "7d",
|
|
341
|
+
value: "7d",
|
|
342
|
+
getFrom: () => new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3).toISOString()
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
label: "30d",
|
|
346
|
+
value: "30d",
|
|
347
|
+
getFrom: () => new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3).toISOString()
|
|
348
|
+
},
|
|
349
|
+
{ label: "All", value: "all", getFrom: () => void 0 }
|
|
350
|
+
];
|
|
351
|
+
/** Category filter options */
|
|
352
|
+
this.categoryFilters = [
|
|
353
|
+
{ value: "all", label: "All" },
|
|
354
|
+
{ value: "content", label: "Content" },
|
|
355
|
+
{ value: "api", label: "API" },
|
|
356
|
+
{ value: "page", label: "Page" },
|
|
357
|
+
{ value: "custom", label: "Custom" }
|
|
358
|
+
];
|
|
359
|
+
/** Selected date range */
|
|
360
|
+
this.selectedRange = signal3("all");
|
|
361
|
+
/** Selected category filter */
|
|
362
|
+
this.selectedCategory = signal3("all");
|
|
363
|
+
/** Search term */
|
|
364
|
+
this.searchTerm = signal3("");
|
|
365
|
+
/** Current page for pagination */
|
|
366
|
+
this.currentPage = signal3(1);
|
|
367
|
+
/** Events per page */
|
|
368
|
+
this.pageSize = 20;
|
|
369
|
+
/** Filtered events based on selected category (client-side filter on top of server query) */
|
|
370
|
+
this.filteredEvents = computed2(() => {
|
|
371
|
+
const result = this.analytics.events();
|
|
372
|
+
if (!result)
|
|
373
|
+
return [];
|
|
374
|
+
const cat = this.selectedCategory();
|
|
375
|
+
if (cat === "all")
|
|
376
|
+
return result.events;
|
|
377
|
+
return result.events.filter((e) => e.category === cat);
|
|
378
|
+
});
|
|
379
|
+
/** Total pages for pagination */
|
|
380
|
+
this.totalPages = computed2(() => {
|
|
381
|
+
const result = this.analytics.events();
|
|
382
|
+
if (!result)
|
|
383
|
+
return 1;
|
|
384
|
+
return Math.max(1, Math.ceil(result.total / result.limit));
|
|
385
|
+
});
|
|
386
|
+
/** Collection breakdown entries */
|
|
387
|
+
this.collectionEntries = computed2(() => {
|
|
388
|
+
const s = this.analytics.summary();
|
|
389
|
+
if (!s)
|
|
390
|
+
return [];
|
|
391
|
+
return Object.entries(s.byCollection).map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
ngOnInit() {
|
|
395
|
+
if (!isPlatformBrowser2(this.platformId))
|
|
396
|
+
return;
|
|
397
|
+
void this.refresh();
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Refresh all analytics data.
|
|
401
|
+
*/
|
|
402
|
+
async refresh() {
|
|
403
|
+
const dateRange = this.dateRanges.find((r) => r.value === this.selectedRange());
|
|
404
|
+
const from = dateRange?.getFrom();
|
|
405
|
+
const search = this.searchTerm() || void 0;
|
|
406
|
+
const page = this.currentPage();
|
|
407
|
+
await Promise.all([
|
|
408
|
+
this.analytics.fetchSummary({ from }),
|
|
409
|
+
this.analytics.queryEvents({
|
|
410
|
+
limit: this.pageSize,
|
|
411
|
+
page,
|
|
412
|
+
from,
|
|
413
|
+
search
|
|
414
|
+
})
|
|
415
|
+
]);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Set date range and refresh.
|
|
419
|
+
*/
|
|
420
|
+
setDateRange(range) {
|
|
421
|
+
this.selectedRange.set(range.value);
|
|
422
|
+
this.currentPage.set(1);
|
|
423
|
+
void this.refresh();
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Set category filter.
|
|
427
|
+
*/
|
|
428
|
+
setCategory(category) {
|
|
429
|
+
this.selectedCategory.set(category);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Handle search input.
|
|
433
|
+
*/
|
|
434
|
+
onSearch(event) {
|
|
435
|
+
const target = event.target;
|
|
436
|
+
if (target instanceof HTMLInputElement) {
|
|
437
|
+
this.searchTerm.set(target.value);
|
|
438
|
+
this.currentPage.set(1);
|
|
439
|
+
void this.refresh();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Navigate to a specific page.
|
|
444
|
+
*/
|
|
445
|
+
goToPage(page) {
|
|
446
|
+
this.currentPage.set(page);
|
|
447
|
+
void this.refresh();
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Get total content operations count.
|
|
451
|
+
*/
|
|
452
|
+
contentOpsTotal(s) {
|
|
453
|
+
return s.contentOperations.created + s.contentOperations.updated + s.contentOperations.deleted;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Check if summary has device/browser data.
|
|
457
|
+
*/
|
|
458
|
+
hasDeviceData(s) {
|
|
459
|
+
return Object.keys(s.deviceBreakdown ?? {}).length > 0 || Object.keys(s.browserBreakdown ?? {}).length > 0;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Get device entries sorted by count.
|
|
463
|
+
*/
|
|
464
|
+
deviceEntries(s) {
|
|
465
|
+
return Object.entries(s.deviceBreakdown ?? {}).map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Get browser entries sorted by count.
|
|
469
|
+
*/
|
|
470
|
+
browserEntries(s) {
|
|
471
|
+
return Object.entries(s.browserBreakdown ?? {}).map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Truncate URL for display.
|
|
475
|
+
*/
|
|
476
|
+
truncateUrl(url) {
|
|
477
|
+
try {
|
|
478
|
+
const parsed = new URL(url, "http://localhost");
|
|
479
|
+
return parsed.pathname + parsed.search;
|
|
480
|
+
} catch {
|
|
481
|
+
return url.length > 50 ? url.slice(0, 47) + "..." : url;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Format timestamp to relative time.
|
|
486
|
+
*/
|
|
487
|
+
formatTime(timestamp) {
|
|
488
|
+
const now = Date.now();
|
|
489
|
+
const then = new Date(timestamp).getTime();
|
|
490
|
+
const diff = now - then;
|
|
491
|
+
if (diff < 6e4)
|
|
492
|
+
return "Just now";
|
|
493
|
+
if (diff < 36e5)
|
|
494
|
+
return `${Math.floor(diff / 6e4)}m ago`;
|
|
495
|
+
if (diff < 864e5)
|
|
496
|
+
return `${Math.floor(diff / 36e5)}h ago`;
|
|
497
|
+
return `${Math.floor(diff / 864e5)}d ago`;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Get badge variant for event category.
|
|
501
|
+
*/
|
|
502
|
+
getCategoryVariant(category) {
|
|
503
|
+
switch (category) {
|
|
504
|
+
case "content":
|
|
505
|
+
return "default";
|
|
506
|
+
case "api":
|
|
507
|
+
return "secondary";
|
|
508
|
+
case "admin":
|
|
509
|
+
return "warning";
|
|
510
|
+
case "page":
|
|
511
|
+
return "success";
|
|
512
|
+
case "custom":
|
|
513
|
+
return "outline";
|
|
514
|
+
default:
|
|
515
|
+
return "secondary";
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Humanize event name (e.g., 'content_created' -> 'Content Created').
|
|
520
|
+
*/
|
|
521
|
+
humanizeEventName(name) {
|
|
522
|
+
return name.split(/[_-]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
AnalyticsDashboardPage = __decorateClass([
|
|
526
|
+
Component2({
|
|
527
|
+
selector: "mcms-analytics-dashboard",
|
|
528
|
+
imports: [
|
|
529
|
+
Card2,
|
|
530
|
+
CardHeader2,
|
|
531
|
+
CardTitle2,
|
|
532
|
+
CardDescription,
|
|
533
|
+
CardContent2,
|
|
534
|
+
Badge2,
|
|
535
|
+
Skeleton2,
|
|
536
|
+
NgIcon2,
|
|
537
|
+
BlockAnalyticsWidgetComponent
|
|
538
|
+
],
|
|
539
|
+
providers: [
|
|
540
|
+
provideIcons2({
|
|
541
|
+
heroChartBarSquare,
|
|
542
|
+
heroArrowTrendingUp,
|
|
543
|
+
heroClock,
|
|
544
|
+
heroUsers,
|
|
545
|
+
heroDocumentText,
|
|
546
|
+
heroArrowPath,
|
|
547
|
+
heroMagnifyingGlass,
|
|
548
|
+
heroDevicePhoneMobile,
|
|
549
|
+
heroGlobeAlt,
|
|
550
|
+
heroChevronLeft,
|
|
551
|
+
heroChevronRight
|
|
552
|
+
})
|
|
553
|
+
],
|
|
554
|
+
changeDetection: ChangeDetectionStrategy2.OnPush,
|
|
555
|
+
host: { class: "block max-w-6xl" },
|
|
556
|
+
template: `
|
|
557
|
+
<header class="mb-10">
|
|
558
|
+
<div class="flex items-center justify-between">
|
|
559
|
+
<div>
|
|
560
|
+
<h1 class="text-4xl font-bold tracking-tight text-foreground">Analytics</h1>
|
|
561
|
+
<p class="text-muted-foreground mt-3 text-lg">
|
|
562
|
+
Monitor content activity and site performance
|
|
563
|
+
</p>
|
|
564
|
+
</div>
|
|
565
|
+
<button
|
|
566
|
+
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md
|
|
567
|
+
bg-primary text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
|
|
568
|
+
(click)="refresh()"
|
|
569
|
+
aria-label="Refresh analytics data"
|
|
570
|
+
>
|
|
571
|
+
<ng-icon name="heroArrowPath" size="16" aria-hidden="true" />
|
|
572
|
+
Refresh
|
|
573
|
+
</button>
|
|
574
|
+
</div>
|
|
575
|
+
</header>
|
|
576
|
+
|
|
577
|
+
<!-- Date Range Selector -->
|
|
578
|
+
<div class="flex gap-2 mb-6">
|
|
579
|
+
@for (range of dateRanges; track range.value) {
|
|
580
|
+
<button
|
|
581
|
+
class="px-3 py-1.5 text-sm rounded-md transition-colors cursor-pointer border
|
|
582
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2"
|
|
583
|
+
[class]="
|
|
584
|
+
selectedRange() === range.value
|
|
585
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
586
|
+
: 'bg-background text-foreground border-border hover:bg-muted'
|
|
587
|
+
"
|
|
588
|
+
(click)="setDateRange(range)"
|
|
589
|
+
>
|
|
590
|
+
{{ range.label }}
|
|
591
|
+
</button>
|
|
592
|
+
}
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
<!-- Overview Cards -->
|
|
596
|
+
<section class="mb-10">
|
|
597
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
|
|
598
|
+
Overview
|
|
599
|
+
</h2>
|
|
600
|
+
@if (analytics.loading() && !analytics.summary()) {
|
|
601
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
602
|
+
@for (i of [1, 2, 3, 4]; track i) {
|
|
603
|
+
<mcms-card>
|
|
604
|
+
<mcms-card-header>
|
|
605
|
+
<mcms-skeleton class="h-4 w-24" />
|
|
606
|
+
<mcms-skeleton class="h-8 w-16 mt-2" />
|
|
607
|
+
</mcms-card-header>
|
|
608
|
+
</mcms-card>
|
|
609
|
+
}
|
|
610
|
+
</div>
|
|
611
|
+
} @else if (analytics.summary(); as s) {
|
|
612
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
613
|
+
<!-- Total Events -->
|
|
614
|
+
<mcms-card>
|
|
615
|
+
<mcms-card-header>
|
|
616
|
+
<div class="flex items-center justify-between">
|
|
617
|
+
<mcms-card-description>Total Events</mcms-card-description>
|
|
618
|
+
<ng-icon
|
|
619
|
+
name="heroChartBarSquare"
|
|
620
|
+
class="text-muted-foreground"
|
|
621
|
+
size="20"
|
|
622
|
+
aria-hidden="true"
|
|
623
|
+
/>
|
|
624
|
+
</div>
|
|
625
|
+
<mcms-card-title>
|
|
626
|
+
<span class="text-3xl font-bold">{{ s.totalEvents }}</span>
|
|
627
|
+
</mcms-card-title>
|
|
628
|
+
</mcms-card-header>
|
|
629
|
+
</mcms-card>
|
|
630
|
+
|
|
631
|
+
<!-- Content Operations -->
|
|
632
|
+
<mcms-card>
|
|
633
|
+
<mcms-card-header>
|
|
634
|
+
<div class="flex items-center justify-between">
|
|
635
|
+
<mcms-card-description>Content Operations</mcms-card-description>
|
|
636
|
+
<ng-icon
|
|
637
|
+
name="heroDocumentText"
|
|
638
|
+
class="text-muted-foreground"
|
|
639
|
+
size="20"
|
|
640
|
+
aria-hidden="true"
|
|
641
|
+
/>
|
|
642
|
+
</div>
|
|
643
|
+
<mcms-card-title>
|
|
644
|
+
<span class="text-3xl font-bold">{{ contentOpsTotal(s) }}</span>
|
|
645
|
+
</mcms-card-title>
|
|
646
|
+
<p class="text-xs text-muted-foreground mt-1">
|
|
647
|
+
{{ s.contentOperations.created }} created /
|
|
648
|
+
{{ s.contentOperations.updated }} updated /
|
|
649
|
+
{{ s.contentOperations.deleted }} deleted
|
|
650
|
+
</p>
|
|
651
|
+
</mcms-card-header>
|
|
652
|
+
</mcms-card>
|
|
653
|
+
|
|
654
|
+
<!-- API Requests -->
|
|
655
|
+
<mcms-card>
|
|
656
|
+
<mcms-card-header>
|
|
657
|
+
<div class="flex items-center justify-between">
|
|
658
|
+
<mcms-card-description>API Requests</mcms-card-description>
|
|
659
|
+
<ng-icon
|
|
660
|
+
name="heroArrowTrendingUp"
|
|
661
|
+
class="text-muted-foreground"
|
|
662
|
+
size="20"
|
|
663
|
+
aria-hidden="true"
|
|
664
|
+
/>
|
|
665
|
+
</div>
|
|
666
|
+
<mcms-card-title>
|
|
667
|
+
<span class="text-3xl font-bold">{{ s.apiMetrics.totalRequests }}</span>
|
|
668
|
+
</mcms-card-title>
|
|
669
|
+
@if (s.apiMetrics.avgDuration > 0) {
|
|
670
|
+
<p class="text-xs text-muted-foreground mt-1">
|
|
671
|
+
Avg. {{ s.apiMetrics.avgDuration }}ms response time
|
|
672
|
+
</p>
|
|
673
|
+
}
|
|
674
|
+
</mcms-card-header>
|
|
675
|
+
</mcms-card>
|
|
676
|
+
|
|
677
|
+
<!-- Active Sessions -->
|
|
678
|
+
<mcms-card>
|
|
679
|
+
<mcms-card-header>
|
|
680
|
+
<div class="flex items-center justify-between">
|
|
681
|
+
<mcms-card-description>Active Sessions</mcms-card-description>
|
|
682
|
+
<ng-icon
|
|
683
|
+
name="heroUsers"
|
|
684
|
+
class="text-muted-foreground"
|
|
685
|
+
size="20"
|
|
686
|
+
aria-hidden="true"
|
|
687
|
+
/>
|
|
688
|
+
</div>
|
|
689
|
+
<mcms-card-title>
|
|
690
|
+
<span class="text-3xl font-bold">{{ s.activeSessions }}</span>
|
|
691
|
+
</mcms-card-title>
|
|
692
|
+
<p class="text-xs text-muted-foreground mt-1">
|
|
693
|
+
{{ s.activeVisitors }} unique visitors
|
|
694
|
+
</p>
|
|
695
|
+
</mcms-card-header>
|
|
696
|
+
</mcms-card>
|
|
697
|
+
</div>
|
|
698
|
+
} @else if (analytics.error(); as err) {
|
|
699
|
+
<mcms-card>
|
|
700
|
+
<mcms-card-header>
|
|
701
|
+
<mcms-card-title>Error loading analytics</mcms-card-title>
|
|
702
|
+
<mcms-card-description>{{ err }}</mcms-card-description>
|
|
703
|
+
</mcms-card-header>
|
|
704
|
+
<mcms-card-content>
|
|
705
|
+
<button class="text-sm text-primary hover:underline cursor-pointer" (click)="refresh()">
|
|
706
|
+
Try again
|
|
707
|
+
</button>
|
|
708
|
+
</mcms-card-content>
|
|
709
|
+
</mcms-card>
|
|
710
|
+
}
|
|
711
|
+
</section>
|
|
712
|
+
|
|
713
|
+
<!-- Recent Activity -->
|
|
714
|
+
<section class="mb-10">
|
|
715
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
|
|
716
|
+
Recent Activity
|
|
717
|
+
</h2>
|
|
718
|
+
|
|
719
|
+
<!-- Search + Category filter row -->
|
|
720
|
+
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
|
721
|
+
<div class="relative flex-1 max-w-sm">
|
|
722
|
+
<ng-icon
|
|
723
|
+
name="heroMagnifyingGlass"
|
|
724
|
+
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
725
|
+
size="16"
|
|
726
|
+
aria-hidden="true"
|
|
727
|
+
/>
|
|
728
|
+
<input
|
|
729
|
+
type="text"
|
|
730
|
+
placeholder="Search events..."
|
|
731
|
+
class="w-full pl-9 pr-3 py-2 text-sm rounded-md border border-border bg-background
|
|
732
|
+
text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2
|
|
733
|
+
focus:ring-primary/50"
|
|
734
|
+
[value]="searchTerm()"
|
|
735
|
+
(input)="onSearch($event)"
|
|
736
|
+
aria-label="Search analytics events"
|
|
737
|
+
/>
|
|
738
|
+
</div>
|
|
739
|
+
<div class="flex gap-2">
|
|
740
|
+
@for (cat of categoryFilters; track cat.value) {
|
|
741
|
+
<button
|
|
742
|
+
class="px-3 py-1.5 text-sm rounded-md transition-colors cursor-pointer border
|
|
743
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2"
|
|
744
|
+
[class]="
|
|
745
|
+
selectedCategory() === cat.value
|
|
746
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
747
|
+
: 'bg-background text-foreground border-border hover:bg-muted'
|
|
748
|
+
"
|
|
749
|
+
(click)="setCategory(cat.value)"
|
|
750
|
+
>
|
|
751
|
+
{{ cat.label }}
|
|
752
|
+
</button>
|
|
753
|
+
}
|
|
754
|
+
</div>
|
|
755
|
+
</div>
|
|
756
|
+
|
|
757
|
+
@if (analytics.loading() && !analytics.events()) {
|
|
758
|
+
<div class="space-y-3">
|
|
759
|
+
@for (i of [1, 2, 3, 4, 5]; track i) {
|
|
760
|
+
<mcms-skeleton class="h-12 w-full" />
|
|
761
|
+
}
|
|
762
|
+
</div>
|
|
763
|
+
} @else if (filteredEvents().length > 0) {
|
|
764
|
+
<div class="border border-border rounded-lg overflow-hidden">
|
|
765
|
+
<div class="overflow-x-auto">
|
|
766
|
+
<table class="w-full text-sm" role="table">
|
|
767
|
+
<thead>
|
|
768
|
+
<tr class="border-b border-border bg-muted/50">
|
|
769
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
770
|
+
Time
|
|
771
|
+
</th>
|
|
772
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
773
|
+
Category
|
|
774
|
+
</th>
|
|
775
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
776
|
+
Event
|
|
777
|
+
</th>
|
|
778
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
779
|
+
URL
|
|
780
|
+
</th>
|
|
781
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
782
|
+
Collection
|
|
783
|
+
</th>
|
|
784
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
785
|
+
Device
|
|
786
|
+
</th>
|
|
787
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
788
|
+
Source
|
|
789
|
+
</th>
|
|
790
|
+
</tr>
|
|
791
|
+
</thead>
|
|
792
|
+
<tbody>
|
|
793
|
+
@for (event of filteredEvents(); track event.id) {
|
|
794
|
+
<tr
|
|
795
|
+
class="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
|
|
796
|
+
>
|
|
797
|
+
<td class="px-4 py-3 text-muted-foreground whitespace-nowrap">
|
|
798
|
+
{{ formatTime(event.timestamp) }}
|
|
799
|
+
</td>
|
|
800
|
+
<td class="px-4 py-3">
|
|
801
|
+
<mcms-badge [variant]="getCategoryVariant(event.category)">
|
|
802
|
+
{{ event.category }}
|
|
803
|
+
</mcms-badge>
|
|
804
|
+
</td>
|
|
805
|
+
<td class="px-4 py-3 font-medium">{{ humanizeEventName(event.name) }}</td>
|
|
806
|
+
<td
|
|
807
|
+
class="px-4 py-3 text-muted-foreground max-w-48 truncate"
|
|
808
|
+
[title]="event.context.url ?? ''"
|
|
809
|
+
>
|
|
810
|
+
@if (event.context.url) {
|
|
811
|
+
{{ truncateUrl(event.context.url) }}
|
|
812
|
+
} @else {
|
|
813
|
+
<span class="text-muted-foreground/50">\u2014</span>
|
|
814
|
+
}
|
|
815
|
+
</td>
|
|
816
|
+
<td class="px-4 py-3 text-muted-foreground">
|
|
817
|
+
@if (event.context.collection) {
|
|
818
|
+
<mcms-badge variant="outline">{{ event.context.collection }}</mcms-badge>
|
|
819
|
+
} @else {
|
|
820
|
+
<span class="text-muted-foreground/50">\u2014</span>
|
|
821
|
+
}
|
|
822
|
+
</td>
|
|
823
|
+
<td class="px-4 py-3 text-muted-foreground whitespace-nowrap">
|
|
824
|
+
@if (event.context.device) {
|
|
825
|
+
{{ event.context.device }}
|
|
826
|
+
} @else {
|
|
827
|
+
<span class="text-muted-foreground/50">\u2014</span>
|
|
828
|
+
}
|
|
829
|
+
</td>
|
|
830
|
+
<td class="px-4 py-3 text-muted-foreground capitalize">
|
|
831
|
+
{{ event.context.source }}
|
|
832
|
+
</td>
|
|
833
|
+
</tr>
|
|
834
|
+
}
|
|
835
|
+
</tbody>
|
|
836
|
+
</table>
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
|
|
840
|
+
<!-- Pagination -->
|
|
841
|
+
@if (totalPages() > 1) {
|
|
842
|
+
<div class="flex items-center justify-between mt-4">
|
|
843
|
+
<p class="text-sm text-muted-foreground">
|
|
844
|
+
Page {{ currentPage() }} of {{ totalPages() }} ({{ analytics.events()?.total ?? 0 }}
|
|
845
|
+
total)
|
|
846
|
+
</p>
|
|
847
|
+
<div class="flex gap-2">
|
|
848
|
+
<button
|
|
849
|
+
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border
|
|
850
|
+
border-border bg-background hover:bg-muted transition-colors cursor-pointer
|
|
851
|
+
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
852
|
+
[disabled]="currentPage() <= 1"
|
|
853
|
+
(click)="goToPage(currentPage() - 1)"
|
|
854
|
+
aria-label="Previous page"
|
|
855
|
+
>
|
|
856
|
+
<ng-icon name="heroChevronLeft" size="14" aria-hidden="true" />
|
|
857
|
+
Prev
|
|
858
|
+
</button>
|
|
859
|
+
<button
|
|
860
|
+
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border
|
|
861
|
+
border-border bg-background hover:bg-muted transition-colors cursor-pointer
|
|
862
|
+
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
863
|
+
[disabled]="currentPage() >= totalPages()"
|
|
864
|
+
(click)="goToPage(currentPage() + 1)"
|
|
865
|
+
aria-label="Next page"
|
|
866
|
+
>
|
|
867
|
+
Next
|
|
868
|
+
<ng-icon name="heroChevronRight" size="14" aria-hidden="true" />
|
|
869
|
+
</button>
|
|
870
|
+
</div>
|
|
871
|
+
</div>
|
|
872
|
+
}
|
|
873
|
+
} @else {
|
|
874
|
+
<mcms-card>
|
|
875
|
+
<mcms-card-content>
|
|
876
|
+
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
877
|
+
<ng-icon
|
|
878
|
+
name="heroChartBarSquare"
|
|
879
|
+
class="text-muted-foreground mb-4"
|
|
880
|
+
size="40"
|
|
881
|
+
aria-hidden="true"
|
|
882
|
+
/>
|
|
883
|
+
<p class="text-foreground font-medium">No events recorded</p>
|
|
884
|
+
<p class="text-sm text-muted-foreground mt-1">
|
|
885
|
+
Events will appear here as they are tracked
|
|
886
|
+
</p>
|
|
887
|
+
</div>
|
|
888
|
+
</mcms-card-content>
|
|
889
|
+
</mcms-card>
|
|
890
|
+
}
|
|
891
|
+
</section>
|
|
892
|
+
|
|
893
|
+
<!-- Top Pages + Device Breakdown side by side -->
|
|
894
|
+
@if (analytics.summary(); as s) {
|
|
895
|
+
@if (s.topPages.length > 0 || hasDeviceData(s)) {
|
|
896
|
+
<section class="mb-10 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
897
|
+
<!-- Top Pages -->
|
|
898
|
+
@if (s.topPages.length > 0) {
|
|
899
|
+
<mcms-card>
|
|
900
|
+
<mcms-card-header>
|
|
901
|
+
<div class="flex items-center gap-2">
|
|
902
|
+
<ng-icon
|
|
903
|
+
name="heroGlobeAlt"
|
|
904
|
+
class="text-muted-foreground"
|
|
905
|
+
size="18"
|
|
906
|
+
aria-hidden="true"
|
|
907
|
+
/>
|
|
908
|
+
<mcms-card-title>Top Pages</mcms-card-title>
|
|
909
|
+
</div>
|
|
910
|
+
</mcms-card-header>
|
|
911
|
+
<mcms-card-content>
|
|
912
|
+
<div class="space-y-3">
|
|
913
|
+
@for (page of s.topPages; track page.url) {
|
|
914
|
+
<div class="flex items-center justify-between">
|
|
915
|
+
<span class="text-sm text-foreground truncate max-w-64" [title]="page.url">
|
|
916
|
+
{{ truncateUrl(page.url) }}
|
|
917
|
+
</span>
|
|
918
|
+
<mcms-badge variant="secondary">{{ page.count }}</mcms-badge>
|
|
919
|
+
</div>
|
|
920
|
+
}
|
|
921
|
+
</div>
|
|
922
|
+
</mcms-card-content>
|
|
923
|
+
</mcms-card>
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
<!-- Device Breakdown -->
|
|
927
|
+
@if (hasDeviceData(s)) {
|
|
928
|
+
<mcms-card>
|
|
929
|
+
<mcms-card-header>
|
|
930
|
+
<div class="flex items-center gap-2">
|
|
931
|
+
<ng-icon
|
|
932
|
+
name="heroDevicePhoneMobile"
|
|
933
|
+
class="text-muted-foreground"
|
|
934
|
+
size="18"
|
|
935
|
+
aria-hidden="true"
|
|
936
|
+
/>
|
|
937
|
+
<mcms-card-title>Devices & Browsers</mcms-card-title>
|
|
938
|
+
</div>
|
|
939
|
+
</mcms-card-header>
|
|
940
|
+
<mcms-card-content>
|
|
941
|
+
@if (deviceEntries(s).length > 0) {
|
|
942
|
+
<h3
|
|
943
|
+
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2"
|
|
944
|
+
>
|
|
945
|
+
Devices
|
|
946
|
+
</h3>
|
|
947
|
+
<div class="space-y-2 mb-4">
|
|
948
|
+
@for (entry of deviceEntries(s); track entry.name) {
|
|
949
|
+
<div class="flex items-center justify-between">
|
|
950
|
+
<span class="text-sm text-foreground capitalize">{{ entry.name }}</span>
|
|
951
|
+
<mcms-badge variant="outline">{{ entry.count }}</mcms-badge>
|
|
952
|
+
</div>
|
|
953
|
+
}
|
|
954
|
+
</div>
|
|
955
|
+
}
|
|
956
|
+
@if (browserEntries(s).length > 0) {
|
|
957
|
+
<h3
|
|
958
|
+
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2"
|
|
959
|
+
>
|
|
960
|
+
Browsers
|
|
961
|
+
</h3>
|
|
962
|
+
<div class="space-y-2">
|
|
963
|
+
@for (entry of browserEntries(s); track entry.name) {
|
|
964
|
+
<div class="flex items-center justify-between">
|
|
965
|
+
<span class="text-sm text-foreground">{{ entry.name }}</span>
|
|
966
|
+
<mcms-badge variant="outline">{{ entry.count }}</mcms-badge>
|
|
967
|
+
</div>
|
|
968
|
+
}
|
|
969
|
+
</div>
|
|
970
|
+
}
|
|
971
|
+
</mcms-card-content>
|
|
972
|
+
</mcms-card>
|
|
973
|
+
}
|
|
974
|
+
</section>
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
<!-- Content Breakdown -->
|
|
979
|
+
@if (collectionEntries().length > 0) {
|
|
980
|
+
<section>
|
|
981
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
|
|
982
|
+
Content Breakdown
|
|
983
|
+
</h2>
|
|
984
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
985
|
+
@for (entry of collectionEntries(); track entry.name) {
|
|
986
|
+
<mcms-card>
|
|
987
|
+
<mcms-card-header>
|
|
988
|
+
<div class="flex items-center justify-between">
|
|
989
|
+
<mcms-card-title>{{ entry.name }}</mcms-card-title>
|
|
990
|
+
<mcms-badge variant="secondary">{{ entry.count }} events</mcms-badge>
|
|
991
|
+
</div>
|
|
992
|
+
</mcms-card-header>
|
|
993
|
+
</mcms-card>
|
|
994
|
+
}
|
|
995
|
+
</div>
|
|
996
|
+
</section>
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
<!-- Block Engagement -->
|
|
1000
|
+
<section class="mt-10">
|
|
1001
|
+
<mcms-block-analytics-widget />
|
|
1002
|
+
</section>
|
|
1003
|
+
`
|
|
1004
|
+
})
|
|
1005
|
+
], AnalyticsDashboardPage);
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// libs/plugins/analytics/src/lib/dashboard/content-performance.service.ts
|
|
1010
|
+
import { Injectable as Injectable2, signal as signal4 } from "@angular/core";
|
|
1011
|
+
function isQueryResult(val) {
|
|
1012
|
+
if (val == null || typeof val !== "object")
|
|
1013
|
+
return false;
|
|
1014
|
+
if (!("events" in val))
|
|
1015
|
+
return false;
|
|
1016
|
+
return Array.isArray(val.events);
|
|
1017
|
+
}
|
|
1018
|
+
function extractPathname(url) {
|
|
1019
|
+
try {
|
|
1020
|
+
return new URL(url).pathname;
|
|
1021
|
+
} catch {
|
|
1022
|
+
return url;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
var ContentPerformanceService;
|
|
1026
|
+
var init_content_performance_service = __esm({
|
|
1027
|
+
"libs/plugins/analytics/src/lib/dashboard/content-performance.service.ts"() {
|
|
1028
|
+
"use strict";
|
|
1029
|
+
ContentPerformanceService = class {
|
|
1030
|
+
constructor() {
|
|
1031
|
+
/** Loading state for top pages */
|
|
1032
|
+
this.loading = signal4(false);
|
|
1033
|
+
/** Error state */
|
|
1034
|
+
this.error = signal4(null);
|
|
1035
|
+
/** Aggregated top pages data */
|
|
1036
|
+
this.topPages = signal4([]);
|
|
1037
|
+
/** Loading state for per-document performance */
|
|
1038
|
+
this.detailLoading = signal4(false);
|
|
1039
|
+
/** Per-document performance data */
|
|
1040
|
+
this.detail = signal4(null);
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Fetch top pages by aggregating page_view events.
|
|
1044
|
+
*/
|
|
1045
|
+
async fetchTopPages(params = {}) {
|
|
1046
|
+
this.loading.set(true);
|
|
1047
|
+
this.error.set(null);
|
|
1048
|
+
try {
|
|
1049
|
+
const searchParams = new URLSearchParams();
|
|
1050
|
+
searchParams.set("name", "page_view");
|
|
1051
|
+
searchParams.set("limit", "1000");
|
|
1052
|
+
if (params.from)
|
|
1053
|
+
searchParams.set("from", params.from);
|
|
1054
|
+
if (params.to)
|
|
1055
|
+
searchParams.set("to", params.to);
|
|
1056
|
+
const response = await fetch(`/api/analytics/query?${searchParams.toString()}`);
|
|
1057
|
+
if (!response.ok) {
|
|
1058
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1059
|
+
}
|
|
1060
|
+
const raw = await response.json();
|
|
1061
|
+
if (!isQueryResult(raw)) {
|
|
1062
|
+
this.topPages.set([]);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const urlMap = /* @__PURE__ */ new Map();
|
|
1066
|
+
for (const event of raw.events) {
|
|
1067
|
+
const rawUrl = event.context.url;
|
|
1068
|
+
if (!rawUrl || typeof rawUrl !== "string")
|
|
1069
|
+
continue;
|
|
1070
|
+
const pathname = extractPathname(rawUrl);
|
|
1071
|
+
let entry = urlMap.get(pathname);
|
|
1072
|
+
if (!entry) {
|
|
1073
|
+
entry = { views: 0, visitors: /* @__PURE__ */ new Set(), referrers: /* @__PURE__ */ new Map() };
|
|
1074
|
+
urlMap.set(pathname, entry);
|
|
1075
|
+
}
|
|
1076
|
+
entry.views++;
|
|
1077
|
+
const visitorKey = event.visitorId ?? event.sessionId;
|
|
1078
|
+
if (visitorKey)
|
|
1079
|
+
entry.visitors.add(visitorKey);
|
|
1080
|
+
const ref = event.context.referrer;
|
|
1081
|
+
if (ref && typeof ref === "string") {
|
|
1082
|
+
entry.referrers.set(ref, (entry.referrers.get(ref) ?? 0) + 1);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
const pages = [];
|
|
1086
|
+
for (const [url, entry] of urlMap) {
|
|
1087
|
+
const referrers = [];
|
|
1088
|
+
for (const [referrer, count] of entry.referrers) {
|
|
1089
|
+
referrers.push({ referrer, count });
|
|
1090
|
+
}
|
|
1091
|
+
referrers.sort((a, b) => b.count - a.count);
|
|
1092
|
+
pages.push({
|
|
1093
|
+
url,
|
|
1094
|
+
pageViews: entry.views,
|
|
1095
|
+
uniqueVisitors: entry.visitors.size,
|
|
1096
|
+
referrers
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
pages.sort((a, b) => b.pageViews - a.pageViews);
|
|
1100
|
+
this.topPages.set(pages);
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1103
|
+
this.error.set(message);
|
|
1104
|
+
} finally {
|
|
1105
|
+
this.loading.set(false);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Fetch per-document performance data.
|
|
1110
|
+
*/
|
|
1111
|
+
async fetchPerformance(params) {
|
|
1112
|
+
this.detailLoading.set(true);
|
|
1113
|
+
try {
|
|
1114
|
+
const searchParams = new URLSearchParams();
|
|
1115
|
+
searchParams.set("collection", params.collection);
|
|
1116
|
+
searchParams.set("documentId", params.documentId);
|
|
1117
|
+
if (params.from)
|
|
1118
|
+
searchParams.set("from", params.from);
|
|
1119
|
+
if (params.to)
|
|
1120
|
+
searchParams.set("to", params.to);
|
|
1121
|
+
const url = `/api/analytics/content-performance?${searchParams.toString()}`;
|
|
1122
|
+
const response = await fetch(url);
|
|
1123
|
+
if (!response.ok) {
|
|
1124
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1125
|
+
}
|
|
1126
|
+
const data = await response.json();
|
|
1127
|
+
this.detail.set(data);
|
|
1128
|
+
} catch {
|
|
1129
|
+
this.detail.set(null);
|
|
1130
|
+
} finally {
|
|
1131
|
+
this.detailLoading.set(false);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
ContentPerformanceService = __decorateClass([
|
|
1136
|
+
Injectable2({ providedIn: "root" })
|
|
1137
|
+
], ContentPerformanceService);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
// libs/plugins/analytics/src/lib/dashboard/content-performance.page.ts
|
|
1142
|
+
var content_performance_page_exports = {};
|
|
1143
|
+
__export(content_performance_page_exports, {
|
|
1144
|
+
ContentPerformancePage: () => ContentPerformancePage
|
|
1145
|
+
});
|
|
1146
|
+
import {
|
|
1147
|
+
Component as Component3,
|
|
1148
|
+
ChangeDetectionStrategy as ChangeDetectionStrategy3,
|
|
1149
|
+
inject as inject3,
|
|
1150
|
+
signal as signal5,
|
|
1151
|
+
computed as computed3,
|
|
1152
|
+
PLATFORM_ID as PLATFORM_ID3
|
|
1153
|
+
} from "@angular/core";
|
|
1154
|
+
import { isPlatformBrowser as isPlatformBrowser3 } from "@angular/common";
|
|
1155
|
+
import {
|
|
1156
|
+
Card as Card3,
|
|
1157
|
+
CardHeader as CardHeader3,
|
|
1158
|
+
CardTitle as CardTitle3,
|
|
1159
|
+
CardDescription as CardDescription2,
|
|
1160
|
+
CardContent as CardContent3,
|
|
1161
|
+
Badge as Badge3,
|
|
1162
|
+
Skeleton as Skeleton3
|
|
1163
|
+
} from "@momentumcms/ui";
|
|
1164
|
+
import { NgIcon as NgIcon3, provideIcons as provideIcons3 } from "@ng-icons/core";
|
|
1165
|
+
import {
|
|
1166
|
+
heroGlobeAlt as heroGlobeAlt2,
|
|
1167
|
+
heroUsers as heroUsers2,
|
|
1168
|
+
heroEye,
|
|
1169
|
+
heroDocumentText as heroDocumentText2,
|
|
1170
|
+
heroMagnifyingGlass as heroMagnifyingGlass2,
|
|
1171
|
+
heroChevronDown,
|
|
1172
|
+
heroChevronUp
|
|
1173
|
+
} from "@ng-icons/heroicons/outline";
|
|
1174
|
+
var ContentPerformancePage;
|
|
1175
|
+
var init_content_performance_page = __esm({
|
|
1176
|
+
"libs/plugins/analytics/src/lib/dashboard/content-performance.page.ts"() {
|
|
1177
|
+
"use strict";
|
|
1178
|
+
init_content_performance_service();
|
|
1179
|
+
ContentPerformancePage = class {
|
|
1180
|
+
constructor() {
|
|
1181
|
+
this.service = inject3(ContentPerformanceService);
|
|
1182
|
+
this.platformId = inject3(PLATFORM_ID3);
|
|
1183
|
+
this.dateRanges = [
|
|
1184
|
+
{
|
|
1185
|
+
label: "24h",
|
|
1186
|
+
value: "24h",
|
|
1187
|
+
getFrom: () => new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString()
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
label: "7d",
|
|
1191
|
+
value: "7d",
|
|
1192
|
+
getFrom: () => new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3).toISOString()
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
label: "30d",
|
|
1196
|
+
value: "30d",
|
|
1197
|
+
getFrom: () => new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3).toISOString()
|
|
1198
|
+
},
|
|
1199
|
+
{ label: "All", value: "all", getFrom: () => void 0 }
|
|
1200
|
+
];
|
|
1201
|
+
this.selectedRange = signal5("all");
|
|
1202
|
+
this.searchTerm = signal5("");
|
|
1203
|
+
this.expandedRow = signal5(null);
|
|
1204
|
+
this.filteredPages = computed3(() => {
|
|
1205
|
+
const pages = this.service.topPages();
|
|
1206
|
+
const term = this.searchTerm().toLowerCase().trim();
|
|
1207
|
+
if (!term)
|
|
1208
|
+
return pages;
|
|
1209
|
+
return pages.filter((p) => p.url.toLowerCase().includes(term));
|
|
1210
|
+
});
|
|
1211
|
+
this.totalViews = computed3(() => {
|
|
1212
|
+
const pages = this.service.topPages();
|
|
1213
|
+
let total = 0;
|
|
1214
|
+
for (const p of pages) {
|
|
1215
|
+
total += p.pageViews;
|
|
1216
|
+
}
|
|
1217
|
+
return total;
|
|
1218
|
+
});
|
|
1219
|
+
this.totalVisitors = computed3(() => {
|
|
1220
|
+
const pages = this.service.topPages();
|
|
1221
|
+
let total = 0;
|
|
1222
|
+
for (const p of pages) {
|
|
1223
|
+
total += p.uniqueVisitors;
|
|
1224
|
+
}
|
|
1225
|
+
return total;
|
|
1226
|
+
});
|
|
1227
|
+
this.totalPages = computed3(() => this.service.topPages().length);
|
|
1228
|
+
}
|
|
1229
|
+
ngOnInit() {
|
|
1230
|
+
if (!isPlatformBrowser3(this.platformId))
|
|
1231
|
+
return;
|
|
1232
|
+
void this.fetchData();
|
|
1233
|
+
}
|
|
1234
|
+
setDateRange(range) {
|
|
1235
|
+
this.selectedRange.set(range.value);
|
|
1236
|
+
this.expandedRow.set(null);
|
|
1237
|
+
void this.fetchData();
|
|
1238
|
+
}
|
|
1239
|
+
onSearch(event) {
|
|
1240
|
+
if (event.target instanceof HTMLInputElement) {
|
|
1241
|
+
this.searchTerm.set(event.target.value);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
toggleRow(url) {
|
|
1245
|
+
this.expandedRow.set(this.expandedRow() === url ? null : url);
|
|
1246
|
+
}
|
|
1247
|
+
async fetchData() {
|
|
1248
|
+
const range = this.dateRanges.find((r) => r.value === this.selectedRange());
|
|
1249
|
+
const from = range?.getFrom();
|
|
1250
|
+
await this.service.fetchTopPages({ from });
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
ContentPerformancePage = __decorateClass([
|
|
1254
|
+
Component3({
|
|
1255
|
+
selector: "mcms-content-performance-page",
|
|
1256
|
+
imports: [Card3, CardHeader3, CardTitle3, CardDescription2, CardContent3, Badge3, Skeleton3, NgIcon3],
|
|
1257
|
+
providers: [
|
|
1258
|
+
provideIcons3({
|
|
1259
|
+
heroGlobeAlt: heroGlobeAlt2,
|
|
1260
|
+
heroUsers: heroUsers2,
|
|
1261
|
+
heroEye,
|
|
1262
|
+
heroDocumentText: heroDocumentText2,
|
|
1263
|
+
heroMagnifyingGlass: heroMagnifyingGlass2,
|
|
1264
|
+
heroChevronDown,
|
|
1265
|
+
heroChevronUp
|
|
1266
|
+
})
|
|
1267
|
+
],
|
|
1268
|
+
changeDetection: ChangeDetectionStrategy3.OnPush,
|
|
1269
|
+
host: { class: "block max-w-6xl" },
|
|
1270
|
+
template: `
|
|
1271
|
+
<header class="mb-10">
|
|
1272
|
+
<div class="flex items-center justify-between">
|
|
1273
|
+
<div>
|
|
1274
|
+
<h1 class="text-4xl font-bold tracking-tight text-foreground">Content Performance</h1>
|
|
1275
|
+
<p class="text-muted-foreground mt-3 text-lg">See which content gets the most traffic</p>
|
|
1276
|
+
</div>
|
|
1277
|
+
</div>
|
|
1278
|
+
</header>
|
|
1279
|
+
|
|
1280
|
+
<!-- Date Range Selector -->
|
|
1281
|
+
<div class="flex gap-2 mb-6">
|
|
1282
|
+
@for (range of dateRanges; track range.value) {
|
|
1283
|
+
<button
|
|
1284
|
+
class="px-3 py-1.5 text-sm rounded-md transition-colors cursor-pointer border
|
|
1285
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2"
|
|
1286
|
+
[class]="
|
|
1287
|
+
selectedRange() === range.value
|
|
1288
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
1289
|
+
: 'bg-background text-foreground border-border hover:bg-muted'
|
|
1290
|
+
"
|
|
1291
|
+
(click)="setDateRange(range)"
|
|
1292
|
+
>
|
|
1293
|
+
{{ range.label }}
|
|
1294
|
+
</button>
|
|
1295
|
+
}
|
|
1296
|
+
</div>
|
|
1297
|
+
|
|
1298
|
+
<!-- Summary Cards -->
|
|
1299
|
+
<section class="mb-8">
|
|
1300
|
+
@if (service.loading() && service.topPages().length === 0) {
|
|
1301
|
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
|
1302
|
+
@for (i of [1, 2, 3]; track i) {
|
|
1303
|
+
<mcms-card>
|
|
1304
|
+
<mcms-card-header>
|
|
1305
|
+
<mcms-skeleton class="h-4 w-24" />
|
|
1306
|
+
<mcms-skeleton class="h-8 w-16 mt-2" />
|
|
1307
|
+
</mcms-card-header>
|
|
1308
|
+
</mcms-card>
|
|
1309
|
+
}
|
|
1310
|
+
</div>
|
|
1311
|
+
} @else {
|
|
1312
|
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
|
1313
|
+
<mcms-card>
|
|
1314
|
+
<mcms-card-header>
|
|
1315
|
+
<div class="flex items-center justify-between">
|
|
1316
|
+
<mcms-card-description>Total Views</mcms-card-description>
|
|
1317
|
+
<ng-icon
|
|
1318
|
+
name="heroEye"
|
|
1319
|
+
class="text-muted-foreground"
|
|
1320
|
+
size="20"
|
|
1321
|
+
aria-hidden="true"
|
|
1322
|
+
/>
|
|
1323
|
+
</div>
|
|
1324
|
+
<mcms-card-title>
|
|
1325
|
+
<span class="text-3xl font-bold">{{ totalViews() }}</span>
|
|
1326
|
+
</mcms-card-title>
|
|
1327
|
+
</mcms-card-header>
|
|
1328
|
+
</mcms-card>
|
|
1329
|
+
|
|
1330
|
+
<mcms-card>
|
|
1331
|
+
<mcms-card-header>
|
|
1332
|
+
<div class="flex items-center justify-between">
|
|
1333
|
+
<mcms-card-description>Unique Visitors</mcms-card-description>
|
|
1334
|
+
<ng-icon
|
|
1335
|
+
name="heroUsers"
|
|
1336
|
+
class="text-muted-foreground"
|
|
1337
|
+
size="20"
|
|
1338
|
+
aria-hidden="true"
|
|
1339
|
+
/>
|
|
1340
|
+
</div>
|
|
1341
|
+
<mcms-card-title>
|
|
1342
|
+
<span class="text-3xl font-bold">{{ totalVisitors() }}</span>
|
|
1343
|
+
</mcms-card-title>
|
|
1344
|
+
</mcms-card-header>
|
|
1345
|
+
</mcms-card>
|
|
1346
|
+
|
|
1347
|
+
<mcms-card>
|
|
1348
|
+
<mcms-card-header>
|
|
1349
|
+
<div class="flex items-center justify-between">
|
|
1350
|
+
<mcms-card-description>Pages</mcms-card-description>
|
|
1351
|
+
<ng-icon
|
|
1352
|
+
name="heroDocumentText"
|
|
1353
|
+
class="text-muted-foreground"
|
|
1354
|
+
size="20"
|
|
1355
|
+
aria-hidden="true"
|
|
1356
|
+
/>
|
|
1357
|
+
</div>
|
|
1358
|
+
<mcms-card-title>
|
|
1359
|
+
<span class="text-3xl font-bold">{{ totalPages() }}</span>
|
|
1360
|
+
</mcms-card-title>
|
|
1361
|
+
</mcms-card-header>
|
|
1362
|
+
</mcms-card>
|
|
1363
|
+
</div>
|
|
1364
|
+
}
|
|
1365
|
+
</section>
|
|
1366
|
+
|
|
1367
|
+
<!-- Search -->
|
|
1368
|
+
<div class="relative max-w-sm mb-4">
|
|
1369
|
+
<ng-icon
|
|
1370
|
+
name="heroMagnifyingGlass"
|
|
1371
|
+
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
1372
|
+
size="16"
|
|
1373
|
+
aria-hidden="true"
|
|
1374
|
+
/>
|
|
1375
|
+
<input
|
|
1376
|
+
type="text"
|
|
1377
|
+
placeholder="Search pages..."
|
|
1378
|
+
class="w-full pl-9 pr-3 py-2 text-sm rounded-md border border-border bg-background
|
|
1379
|
+
text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2
|
|
1380
|
+
focus:ring-primary/50"
|
|
1381
|
+
[value]="searchTerm()"
|
|
1382
|
+
(input)="onSearch($event)"
|
|
1383
|
+
aria-label="Search content pages"
|
|
1384
|
+
/>
|
|
1385
|
+
</div>
|
|
1386
|
+
|
|
1387
|
+
<!-- Pages Table -->
|
|
1388
|
+
@if (service.loading() && service.topPages().length === 0) {
|
|
1389
|
+
<div class="space-y-3">
|
|
1390
|
+
@for (i of [1, 2, 3, 4, 5]; track i) {
|
|
1391
|
+
<mcms-skeleton class="h-12 w-full" />
|
|
1392
|
+
}
|
|
1393
|
+
</div>
|
|
1394
|
+
} @else if (service.error(); as err) {
|
|
1395
|
+
<mcms-card>
|
|
1396
|
+
<mcms-card-content>
|
|
1397
|
+
<p class="text-sm text-destructive">{{ err }}</p>
|
|
1398
|
+
</mcms-card-content>
|
|
1399
|
+
</mcms-card>
|
|
1400
|
+
} @else if (filteredPages().length > 0) {
|
|
1401
|
+
<div class="border border-border rounded-lg overflow-hidden">
|
|
1402
|
+
<div class="overflow-x-auto">
|
|
1403
|
+
<table class="w-full text-sm" role="table">
|
|
1404
|
+
<thead>
|
|
1405
|
+
<tr class="border-b border-border bg-muted/50">
|
|
1406
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground w-12">
|
|
1407
|
+
#
|
|
1408
|
+
</th>
|
|
1409
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
1410
|
+
Page
|
|
1411
|
+
</th>
|
|
1412
|
+
<th scope="col" class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
1413
|
+
Views
|
|
1414
|
+
</th>
|
|
1415
|
+
<th scope="col" class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
1416
|
+
Visitors
|
|
1417
|
+
</th>
|
|
1418
|
+
<th
|
|
1419
|
+
scope="col"
|
|
1420
|
+
class="px-4 py-3 text-center font-medium text-muted-foreground w-12"
|
|
1421
|
+
>
|
|
1422
|
+
<span class="sr-only">Expand</span>
|
|
1423
|
+
</th>
|
|
1424
|
+
</tr>
|
|
1425
|
+
</thead>
|
|
1426
|
+
<tbody>
|
|
1427
|
+
@for (page of filteredPages(); track page.url; let idx = $index) {
|
|
1428
|
+
<tr
|
|
1429
|
+
class="border-b border-border last:border-0 hover:bg-muted/30 transition-colors cursor-pointer
|
|
1430
|
+
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
1431
|
+
tabindex="0"
|
|
1432
|
+
role="row"
|
|
1433
|
+
[attr.aria-label]="'Show referrers for ' + page.url"
|
|
1434
|
+
[attr.aria-expanded]="expandedRow() === page.url"
|
|
1435
|
+
(click)="toggleRow(page.url)"
|
|
1436
|
+
(keydown.enter)="toggleRow(page.url)"
|
|
1437
|
+
(keydown.space)="$event.preventDefault(); toggleRow(page.url)"
|
|
1438
|
+
>
|
|
1439
|
+
<td class="px-4 py-3 text-muted-foreground">
|
|
1440
|
+
{{ idx + 1 }}
|
|
1441
|
+
</td>
|
|
1442
|
+
<td class="px-4 py-3 font-medium" [title]="page.url">
|
|
1443
|
+
{{ page.url }}
|
|
1444
|
+
</td>
|
|
1445
|
+
<td class="px-4 py-3 text-right text-muted-foreground">
|
|
1446
|
+
{{ page.pageViews }}
|
|
1447
|
+
</td>
|
|
1448
|
+
<td class="px-4 py-3 text-right text-muted-foreground">
|
|
1449
|
+
{{ page.uniqueVisitors }}
|
|
1450
|
+
</td>
|
|
1451
|
+
<td class="px-4 py-3 text-center">
|
|
1452
|
+
<ng-icon
|
|
1453
|
+
[name]="expandedRow() === page.url ? 'heroChevronUp' : 'heroChevronDown'"
|
|
1454
|
+
size="16"
|
|
1455
|
+
class="text-muted-foreground"
|
|
1456
|
+
aria-hidden="true"
|
|
1457
|
+
/>
|
|
1458
|
+
</td>
|
|
1459
|
+
</tr>
|
|
1460
|
+
@if (expandedRow() === page.url) {
|
|
1461
|
+
<tr class="bg-muted/20">
|
|
1462
|
+
<td colspan="5" class="px-4 py-4">
|
|
1463
|
+
@if (page.referrers.length > 0) {
|
|
1464
|
+
<div>
|
|
1465
|
+
<h4
|
|
1466
|
+
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2"
|
|
1467
|
+
>
|
|
1468
|
+
Top Referrers
|
|
1469
|
+
</h4>
|
|
1470
|
+
<div class="flex flex-wrap gap-2">
|
|
1471
|
+
@for (ref of page.referrers; track ref.referrer) {
|
|
1472
|
+
<div
|
|
1473
|
+
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-background border border-border text-sm"
|
|
1474
|
+
>
|
|
1475
|
+
<span class="truncate max-w-48" [title]="ref.referrer">
|
|
1476
|
+
{{ ref.referrer }}
|
|
1477
|
+
</span>
|
|
1478
|
+
<mcms-badge variant="secondary">
|
|
1479
|
+
{{ ref.count }}
|
|
1480
|
+
</mcms-badge>
|
|
1481
|
+
</div>
|
|
1482
|
+
}
|
|
1483
|
+
</div>
|
|
1484
|
+
</div>
|
|
1485
|
+
} @else {
|
|
1486
|
+
<p class="text-sm text-muted-foreground">No referrer data available</p>
|
|
1487
|
+
}
|
|
1488
|
+
</td>
|
|
1489
|
+
</tr>
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
</tbody>
|
|
1493
|
+
</table>
|
|
1494
|
+
</div>
|
|
1495
|
+
</div>
|
|
1496
|
+
} @else {
|
|
1497
|
+
<mcms-card>
|
|
1498
|
+
<mcms-card-content>
|
|
1499
|
+
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
1500
|
+
<ng-icon
|
|
1501
|
+
name="heroGlobeAlt"
|
|
1502
|
+
class="text-muted-foreground mb-4"
|
|
1503
|
+
size="40"
|
|
1504
|
+
aria-hidden="true"
|
|
1505
|
+
/>
|
|
1506
|
+
<p class="text-foreground font-medium">No page views recorded</p>
|
|
1507
|
+
<p class="text-sm text-muted-foreground mt-1">
|
|
1508
|
+
Page view events will appear here as visitors browse your site
|
|
1509
|
+
</p>
|
|
1510
|
+
</div>
|
|
1511
|
+
</mcms-card-content>
|
|
1512
|
+
</mcms-card>
|
|
1513
|
+
}
|
|
1514
|
+
`
|
|
1515
|
+
})
|
|
1516
|
+
], ContentPerformancePage);
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
// libs/plugins/analytics/src/lib/dashboard/tracking-rule-form.dialog.ts
|
|
1521
|
+
import {
|
|
1522
|
+
Component as Component4,
|
|
1523
|
+
ChangeDetectionStrategy as ChangeDetectionStrategy4,
|
|
1524
|
+
inject as inject4,
|
|
1525
|
+
signal as signal6,
|
|
1526
|
+
computed as computed4,
|
|
1527
|
+
PLATFORM_ID as PLATFORM_ID4
|
|
1528
|
+
} from "@angular/core";
|
|
1529
|
+
import { isPlatformBrowser as isPlatformBrowser4 } from "@angular/common";
|
|
1530
|
+
import {
|
|
1531
|
+
Dialog,
|
|
1532
|
+
DialogHeader,
|
|
1533
|
+
DialogTitle,
|
|
1534
|
+
DialogContent,
|
|
1535
|
+
DialogFooter,
|
|
1536
|
+
DialogClose,
|
|
1537
|
+
DIALOG_DATA,
|
|
1538
|
+
DialogRef,
|
|
1539
|
+
Input,
|
|
1540
|
+
Select,
|
|
1541
|
+
Switch,
|
|
1542
|
+
Button,
|
|
1543
|
+
McmsFormField
|
|
1544
|
+
} from "@momentumcms/ui";
|
|
1545
|
+
var EVENT_TYPE_OPTIONS, TrackingRuleFormDialog;
|
|
1546
|
+
var init_tracking_rule_form_dialog = __esm({
|
|
1547
|
+
"libs/plugins/analytics/src/lib/dashboard/tracking-rule-form.dialog.ts"() {
|
|
1548
|
+
"use strict";
|
|
1549
|
+
init_type_guards();
|
|
1550
|
+
EVENT_TYPE_OPTIONS = [
|
|
1551
|
+
{ label: "Click", value: "click" },
|
|
1552
|
+
{ label: "Submit", value: "submit" },
|
|
1553
|
+
{ label: "Scroll Into View", value: "scroll-into-view" },
|
|
1554
|
+
{ label: "Hover", value: "hover" },
|
|
1555
|
+
{ label: "Focus", value: "focus" }
|
|
1556
|
+
];
|
|
1557
|
+
TrackingRuleFormDialog = class {
|
|
1558
|
+
constructor() {
|
|
1559
|
+
this.data = inject4(DIALOG_DATA);
|
|
1560
|
+
this.dialogRef = inject4(DialogRef);
|
|
1561
|
+
this.platformId = inject4(PLATFORM_ID4);
|
|
1562
|
+
this.name = signal6(this.data.rule?.name ?? "");
|
|
1563
|
+
this.selector = signal6(this.data.rule?.selector ?? "");
|
|
1564
|
+
this.eventType = signal6(this.data.rule?.eventType ?? "click");
|
|
1565
|
+
this.eventName = signal6(this.data.rule?.eventName ?? "");
|
|
1566
|
+
this.urlPattern = signal6(this.data.rule?.urlPattern ?? "*");
|
|
1567
|
+
this.active = signal6(this.data.rule?.active ?? true);
|
|
1568
|
+
this.rateLimitStr = signal6(this.data.rule?.rateLimit?.toString() ?? "");
|
|
1569
|
+
this.propertiesStr = signal6(
|
|
1570
|
+
this.data.rule?.properties && Object.keys(this.data.rule.properties).length > 0 ? JSON.stringify(this.data.rule.properties) : ""
|
|
1571
|
+
);
|
|
1572
|
+
this.saving = signal6(false);
|
|
1573
|
+
this.submitted = signal6(false);
|
|
1574
|
+
this.eventTypeOptions = EVENT_TYPE_OPTIONS;
|
|
1575
|
+
this.isValid = computed4(
|
|
1576
|
+
() => this.name().trim().length > 0 && this.selector().trim().length > 0 && this.eventName().trim().length > 0
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
async save() {
|
|
1580
|
+
this.submitted.set(true);
|
|
1581
|
+
if (!this.isValid() || this.saving())
|
|
1582
|
+
return;
|
|
1583
|
+
if (!isPlatformBrowser4(this.platformId))
|
|
1584
|
+
return;
|
|
1585
|
+
this.saving.set(true);
|
|
1586
|
+
let properties = {};
|
|
1587
|
+
const propsStr = this.propertiesStr().trim();
|
|
1588
|
+
if (propsStr) {
|
|
1589
|
+
try {
|
|
1590
|
+
const parsed = JSON.parse(propsStr);
|
|
1591
|
+
if (isRecord(parsed)) {
|
|
1592
|
+
properties = parsed;
|
|
1593
|
+
}
|
|
1594
|
+
} catch {
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
const rateLimitVal = this.rateLimitStr().trim();
|
|
1598
|
+
const body = {
|
|
1599
|
+
name: this.name().trim(),
|
|
1600
|
+
selector: this.selector().trim(),
|
|
1601
|
+
eventType: this.eventType(),
|
|
1602
|
+
eventName: this.eventName().trim(),
|
|
1603
|
+
urlPattern: this.urlPattern().trim() || "*",
|
|
1604
|
+
active: this.active(),
|
|
1605
|
+
properties
|
|
1606
|
+
};
|
|
1607
|
+
if (rateLimitVal && !Number.isNaN(Number(rateLimitVal))) {
|
|
1608
|
+
body["rateLimit"] = Number(rateLimitVal);
|
|
1609
|
+
}
|
|
1610
|
+
try {
|
|
1611
|
+
if (this.data.mode === "edit" && this.data.rule) {
|
|
1612
|
+
await fetch(`/api/tracking-rules/${this.data.rule.id}`, {
|
|
1613
|
+
method: "PATCH",
|
|
1614
|
+
headers: { "Content-Type": "application/json" },
|
|
1615
|
+
body: JSON.stringify(body)
|
|
1616
|
+
});
|
|
1617
|
+
} else {
|
|
1618
|
+
await fetch("/api/tracking-rules", {
|
|
1619
|
+
method: "POST",
|
|
1620
|
+
headers: { "Content-Type": "application/json" },
|
|
1621
|
+
body: JSON.stringify(body)
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
this.dialogRef.close("saved");
|
|
1625
|
+
} finally {
|
|
1626
|
+
this.saving.set(false);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
TrackingRuleFormDialog = __decorateClass([
|
|
1631
|
+
Component4({
|
|
1632
|
+
selector: "mcms-tracking-rule-form-dialog",
|
|
1633
|
+
imports: [
|
|
1634
|
+
Dialog,
|
|
1635
|
+
DialogHeader,
|
|
1636
|
+
DialogTitle,
|
|
1637
|
+
DialogContent,
|
|
1638
|
+
DialogFooter,
|
|
1639
|
+
DialogClose,
|
|
1640
|
+
Input,
|
|
1641
|
+
Select,
|
|
1642
|
+
Switch,
|
|
1643
|
+
Button,
|
|
1644
|
+
McmsFormField
|
|
1645
|
+
],
|
|
1646
|
+
changeDetection: ChangeDetectionStrategy4.OnPush,
|
|
1647
|
+
template: `
|
|
1648
|
+
<mcms-dialog>
|
|
1649
|
+
<mcms-dialog-header>
|
|
1650
|
+
<mcms-dialog-title>
|
|
1651
|
+
{{ data.mode === 'create' ? 'Create Tracking Rule' : 'Edit Tracking Rule' }}
|
|
1652
|
+
</mcms-dialog-title>
|
|
1653
|
+
</mcms-dialog-header>
|
|
1654
|
+
|
|
1655
|
+
<mcms-dialog-content>
|
|
1656
|
+
<div class="space-y-4">
|
|
1657
|
+
<mcms-form-field id="rule-name" [required]="true">
|
|
1658
|
+
<span mcmsLabel>Rule Name</span>
|
|
1659
|
+
<mcms-input
|
|
1660
|
+
[(value)]="name"
|
|
1661
|
+
placeholder="e.g. CTA Button Click"
|
|
1662
|
+
id="rule-name"
|
|
1663
|
+
[attr.aria-invalid]="submitted() && name().trim().length === 0"
|
|
1664
|
+
[attr.aria-describedby]="
|
|
1665
|
+
submitted() && name().trim().length === 0 ? 'rule-name-error' : null
|
|
1666
|
+
"
|
|
1667
|
+
/>
|
|
1668
|
+
@if (submitted() && name().trim().length === 0) {
|
|
1669
|
+
<span id="rule-name-error" class="text-sm text-destructive mt-1"
|
|
1670
|
+
>Rule Name is required</span
|
|
1671
|
+
>
|
|
1672
|
+
}
|
|
1673
|
+
</mcms-form-field>
|
|
1674
|
+
|
|
1675
|
+
<mcms-form-field id="rule-selector" [required]="true">
|
|
1676
|
+
<span mcmsLabel>CSS Selector</span>
|
|
1677
|
+
<mcms-input
|
|
1678
|
+
[(value)]="selector"
|
|
1679
|
+
placeholder="e.g. .cta-button, #signup-form"
|
|
1680
|
+
id="rule-selector"
|
|
1681
|
+
[attr.aria-invalid]="submitted() && selector().trim().length === 0"
|
|
1682
|
+
[attr.aria-describedby]="
|
|
1683
|
+
submitted() && selector().trim().length === 0 ? 'rule-selector-error' : null
|
|
1684
|
+
"
|
|
1685
|
+
/>
|
|
1686
|
+
@if (submitted() && selector().trim().length === 0) {
|
|
1687
|
+
<span id="rule-selector-error" class="text-sm text-destructive mt-1"
|
|
1688
|
+
>CSS Selector is required</span
|
|
1689
|
+
>
|
|
1690
|
+
}
|
|
1691
|
+
</mcms-form-field>
|
|
1692
|
+
|
|
1693
|
+
<mcms-form-field id="rule-event-type" [required]="true">
|
|
1694
|
+
<span mcmsLabel>Event Type</span>
|
|
1695
|
+
<mcms-select [(value)]="eventType" [options]="eventTypeOptions" id="rule-event-type" />
|
|
1696
|
+
</mcms-form-field>
|
|
1697
|
+
|
|
1698
|
+
<mcms-form-field id="rule-event-name" [required]="true">
|
|
1699
|
+
<span mcmsLabel>Event Name</span>
|
|
1700
|
+
<mcms-input
|
|
1701
|
+
[(value)]="eventName"
|
|
1702
|
+
placeholder="e.g. cta_click"
|
|
1703
|
+
id="rule-event-name"
|
|
1704
|
+
[attr.aria-invalid]="submitted() && eventName().trim().length === 0"
|
|
1705
|
+
[attr.aria-describedby]="
|
|
1706
|
+
submitted() && eventName().trim().length === 0 ? 'rule-event-name-error' : null
|
|
1707
|
+
"
|
|
1708
|
+
/>
|
|
1709
|
+
@if (submitted() && eventName().trim().length === 0) {
|
|
1710
|
+
<span id="rule-event-name-error" class="text-sm text-destructive mt-1"
|
|
1711
|
+
>Event Name is required</span
|
|
1712
|
+
>
|
|
1713
|
+
}
|
|
1714
|
+
</mcms-form-field>
|
|
1715
|
+
|
|
1716
|
+
<mcms-form-field id="rule-url-pattern" [hint]="'Use * for wildcards, e.g. /blog/*'">
|
|
1717
|
+
<span mcmsLabel>URL Pattern</span>
|
|
1718
|
+
<mcms-input [(value)]="urlPattern" placeholder="*" id="rule-url-pattern" />
|
|
1719
|
+
</mcms-form-field>
|
|
1720
|
+
|
|
1721
|
+
<mcms-form-field
|
|
1722
|
+
id="rule-rate-limit"
|
|
1723
|
+
[hint]="'Max events per minute per visitor. Leave empty for unlimited.'"
|
|
1724
|
+
>
|
|
1725
|
+
<span mcmsLabel>Rate Limit</span>
|
|
1726
|
+
<mcms-input
|
|
1727
|
+
[(value)]="rateLimitStr"
|
|
1728
|
+
type="number"
|
|
1729
|
+
placeholder="Optional"
|
|
1730
|
+
id="rule-rate-limit"
|
|
1731
|
+
/>
|
|
1732
|
+
</mcms-form-field>
|
|
1733
|
+
|
|
1734
|
+
<mcms-form-field
|
|
1735
|
+
id="rule-properties"
|
|
1736
|
+
[hint]="'JSON object, e.g. {"category": "cta"}'"
|
|
1737
|
+
>
|
|
1738
|
+
<span mcmsLabel>Static Properties</span>
|
|
1739
|
+
<mcms-input [(value)]="propertiesStr" placeholder="{}" id="rule-properties" />
|
|
1740
|
+
</mcms-form-field>
|
|
1741
|
+
|
|
1742
|
+
<div class="pt-2">
|
|
1743
|
+
<mcms-switch [(value)]="active">Active</mcms-switch>
|
|
1744
|
+
</div>
|
|
1745
|
+
</div>
|
|
1746
|
+
</mcms-dialog-content>
|
|
1747
|
+
|
|
1748
|
+
<mcms-dialog-footer>
|
|
1749
|
+
<button mcms-button variant="outline" mcmsDialogClose>Cancel</button>
|
|
1750
|
+
<button mcms-button [disabled]="!isValid()" [loading]="saving()" (click)="save()">
|
|
1751
|
+
{{ data.mode === 'create' ? 'Create' : 'Save' }}
|
|
1752
|
+
</button>
|
|
1753
|
+
</mcms-dialog-footer>
|
|
1754
|
+
</mcms-dialog>
|
|
1755
|
+
`
|
|
1756
|
+
})
|
|
1757
|
+
], TrackingRuleFormDialog);
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
// libs/plugins/analytics/src/lib/dashboard/tracking-rules.page.ts
|
|
1762
|
+
var tracking_rules_page_exports = {};
|
|
1763
|
+
__export(tracking_rules_page_exports, {
|
|
1764
|
+
TrackingRulesPage: () => TrackingRulesPage
|
|
1765
|
+
});
|
|
1766
|
+
import {
|
|
1767
|
+
Component as Component5,
|
|
1768
|
+
ChangeDetectionStrategy as ChangeDetectionStrategy5,
|
|
1769
|
+
inject as inject5,
|
|
1770
|
+
signal as signal7,
|
|
1771
|
+
computed as computed5,
|
|
1772
|
+
PLATFORM_ID as PLATFORM_ID5
|
|
1773
|
+
} from "@angular/core";
|
|
1774
|
+
import { isPlatformBrowser as isPlatformBrowser5 } from "@angular/common";
|
|
1775
|
+
import {
|
|
1776
|
+
Card as Card4,
|
|
1777
|
+
CardHeader as CardHeader4,
|
|
1778
|
+
CardTitle as CardTitle4,
|
|
1779
|
+
CardDescription as CardDescription3,
|
|
1780
|
+
Badge as Badge4,
|
|
1781
|
+
Skeleton as Skeleton4,
|
|
1782
|
+
Button as Button2,
|
|
1783
|
+
Switch as Switch2,
|
|
1784
|
+
DialogService,
|
|
1785
|
+
DropdownMenu,
|
|
1786
|
+
DropdownTrigger,
|
|
1787
|
+
DropdownMenuItem,
|
|
1788
|
+
DropdownSeparator
|
|
1789
|
+
} from "@momentumcms/ui";
|
|
1790
|
+
import { ConfirmationService } from "@momentumcms/ui";
|
|
1791
|
+
import { NgIcon as NgIcon4, provideIcons as provideIcons4 } from "@ng-icons/core";
|
|
1792
|
+
import {
|
|
1793
|
+
heroCursorArrowRays,
|
|
1794
|
+
heroArrowPath as heroArrowPath2,
|
|
1795
|
+
heroPlus,
|
|
1796
|
+
heroEllipsisVertical
|
|
1797
|
+
} from "@ng-icons/heroicons/outline";
|
|
1798
|
+
function parseRuleEntry(doc) {
|
|
1799
|
+
if (!isRecord(doc))
|
|
1800
|
+
return null;
|
|
1801
|
+
if (typeof doc["id"] !== "string")
|
|
1802
|
+
return null;
|
|
1803
|
+
return {
|
|
1804
|
+
id: doc["id"],
|
|
1805
|
+
name: typeof doc["name"] === "string" ? doc["name"] : "(unnamed)",
|
|
1806
|
+
selector: typeof doc["selector"] === "string" ? doc["selector"] : "",
|
|
1807
|
+
eventType: typeof doc["eventType"] === "string" ? doc["eventType"] : "click",
|
|
1808
|
+
eventName: typeof doc["eventName"] === "string" ? doc["eventName"] : "",
|
|
1809
|
+
urlPattern: typeof doc["urlPattern"] === "string" ? doc["urlPattern"] : "*",
|
|
1810
|
+
active: doc["active"] === true,
|
|
1811
|
+
rateLimit: typeof doc["rateLimit"] === "number" ? doc["rateLimit"] : void 0,
|
|
1812
|
+
properties: isRecord(doc["properties"]) ? doc["properties"] : void 0
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
var TrackingRulesPage;
|
|
1816
|
+
var init_tracking_rules_page = __esm({
|
|
1817
|
+
"libs/plugins/analytics/src/lib/dashboard/tracking-rules.page.ts"() {
|
|
1818
|
+
"use strict";
|
|
1819
|
+
init_tracking_rule_form_dialog();
|
|
1820
|
+
init_type_guards();
|
|
1821
|
+
TrackingRulesPage = class {
|
|
1822
|
+
constructor() {
|
|
1823
|
+
this.platformId = inject5(PLATFORM_ID5);
|
|
1824
|
+
this.dialog = inject5(DialogService);
|
|
1825
|
+
this.confirmation = inject5(ConfirmationService);
|
|
1826
|
+
this.loading = signal7(false);
|
|
1827
|
+
this.rules = signal7([]);
|
|
1828
|
+
this.totalRules = computed5(() => this.rules().length);
|
|
1829
|
+
this.activeRules = computed5(() => this.rules().filter((r) => r.active).length);
|
|
1830
|
+
this.inactiveRules = computed5(() => this.rules().filter((r) => !r.active).length);
|
|
1831
|
+
}
|
|
1832
|
+
ngOnInit() {
|
|
1833
|
+
if (!isPlatformBrowser5(this.platformId))
|
|
1834
|
+
return;
|
|
1835
|
+
void this.fetchRules();
|
|
1836
|
+
}
|
|
1837
|
+
refresh() {
|
|
1838
|
+
void this.fetchRules();
|
|
1839
|
+
}
|
|
1840
|
+
openCreateDialog() {
|
|
1841
|
+
const data = { mode: "create" };
|
|
1842
|
+
const ref = this.dialog.open(TrackingRuleFormDialog, { data, width: "32rem" });
|
|
1843
|
+
ref.afterClosed.subscribe((result) => {
|
|
1844
|
+
if (result === "saved")
|
|
1845
|
+
void this.fetchRules();
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
openEditDialog(rule) {
|
|
1849
|
+
const data = { mode: "edit", rule };
|
|
1850
|
+
const ref = this.dialog.open(TrackingRuleFormDialog, { data, width: "32rem" });
|
|
1851
|
+
ref.afterClosed.subscribe((result) => {
|
|
1852
|
+
if (result === "saved")
|
|
1853
|
+
void this.fetchRules();
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
async toggleActive(rule) {
|
|
1857
|
+
if (!isPlatformBrowser5(this.platformId))
|
|
1858
|
+
return;
|
|
1859
|
+
const newActive = !rule.active;
|
|
1860
|
+
this.rules.update(
|
|
1861
|
+
(rules) => rules.map((r) => r.id === rule.id ? { ...r, active: newActive } : r)
|
|
1862
|
+
);
|
|
1863
|
+
try {
|
|
1864
|
+
await fetch(`/api/tracking-rules/${rule.id}`, {
|
|
1865
|
+
method: "PATCH",
|
|
1866
|
+
headers: { "Content-Type": "application/json" },
|
|
1867
|
+
body: JSON.stringify({ active: newActive })
|
|
1868
|
+
});
|
|
1869
|
+
} catch {
|
|
1870
|
+
this.rules.update(
|
|
1871
|
+
(rules) => rules.map((r) => r.id === rule.id ? { ...r, active: rule.active } : r)
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
async deleteRule(rule) {
|
|
1876
|
+
if (!isPlatformBrowser5(this.platformId))
|
|
1877
|
+
return;
|
|
1878
|
+
const confirmed = await this.confirmation.delete(rule.name);
|
|
1879
|
+
if (!confirmed)
|
|
1880
|
+
return;
|
|
1881
|
+
try {
|
|
1882
|
+
await fetch(`/api/tracking-rules/${rule.id}`, { method: "DELETE" });
|
|
1883
|
+
void this.fetchRules();
|
|
1884
|
+
} catch {
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
async fetchRules() {
|
|
1888
|
+
this.loading.set(true);
|
|
1889
|
+
try {
|
|
1890
|
+
const res = await fetch("/api/tracking-rules?limit=100");
|
|
1891
|
+
if (!res.ok)
|
|
1892
|
+
return;
|
|
1893
|
+
const data = await res.json();
|
|
1894
|
+
if (!isRecord(data) || !Array.isArray(data["docs"]))
|
|
1895
|
+
return;
|
|
1896
|
+
const entries = data["docs"].map(parseRuleEntry).filter((entry) => entry != null);
|
|
1897
|
+
this.rules.set(entries);
|
|
1898
|
+
} catch {
|
|
1899
|
+
} finally {
|
|
1900
|
+
this.loading.set(false);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
TrackingRulesPage = __decorateClass([
|
|
1905
|
+
Component5({
|
|
1906
|
+
selector: "mcms-tracking-rules-page",
|
|
1907
|
+
imports: [
|
|
1908
|
+
Card4,
|
|
1909
|
+
CardHeader4,
|
|
1910
|
+
CardTitle4,
|
|
1911
|
+
CardDescription3,
|
|
1912
|
+
Badge4,
|
|
1913
|
+
Skeleton4,
|
|
1914
|
+
Button2,
|
|
1915
|
+
Switch2,
|
|
1916
|
+
NgIcon4,
|
|
1917
|
+
DropdownMenu,
|
|
1918
|
+
DropdownTrigger,
|
|
1919
|
+
DropdownMenuItem,
|
|
1920
|
+
DropdownSeparator
|
|
1921
|
+
],
|
|
1922
|
+
providers: [provideIcons4({ heroCursorArrowRays, heroArrowPath: heroArrowPath2, heroPlus, heroEllipsisVertical })],
|
|
1923
|
+
changeDetection: ChangeDetectionStrategy5.OnPush,
|
|
1924
|
+
host: { class: "block max-w-6xl" },
|
|
1925
|
+
template: `
|
|
1926
|
+
<header class="mb-10">
|
|
1927
|
+
<div class="flex items-center justify-between">
|
|
1928
|
+
<div>
|
|
1929
|
+
<h1 class="text-4xl font-bold tracking-tight text-foreground">Tracking Rules</h1>
|
|
1930
|
+
<p class="text-muted-foreground mt-3 text-lg">
|
|
1931
|
+
Manage CSS selector-based event tracking rules
|
|
1932
|
+
</p>
|
|
1933
|
+
</div>
|
|
1934
|
+
<div class="flex items-center gap-2">
|
|
1935
|
+
<button
|
|
1936
|
+
mcms-button
|
|
1937
|
+
variant="outline"
|
|
1938
|
+
size="sm"
|
|
1939
|
+
(click)="refresh()"
|
|
1940
|
+
ariaLabel="Refresh tracking rules"
|
|
1941
|
+
>
|
|
1942
|
+
<ng-icon name="heroArrowPath" size="16" aria-hidden="true" />
|
|
1943
|
+
Refresh
|
|
1944
|
+
</button>
|
|
1945
|
+
<button mcms-button size="sm" (click)="openCreateDialog()" ariaLabel="New Rule">
|
|
1946
|
+
<ng-icon name="heroPlus" size="16" aria-hidden="true" />
|
|
1947
|
+
New Rule
|
|
1948
|
+
</button>
|
|
1949
|
+
</div>
|
|
1950
|
+
</div>
|
|
1951
|
+
</header>
|
|
1952
|
+
|
|
1953
|
+
<!-- Summary Cards -->
|
|
1954
|
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8">
|
|
1955
|
+
<mcms-card>
|
|
1956
|
+
<mcms-card-header>
|
|
1957
|
+
<div class="flex items-center justify-between">
|
|
1958
|
+
<mcms-card-description>Total Rules</mcms-card-description>
|
|
1959
|
+
<ng-icon
|
|
1960
|
+
name="heroCursorArrowRays"
|
|
1961
|
+
class="text-muted-foreground"
|
|
1962
|
+
size="20"
|
|
1963
|
+
aria-hidden="true"
|
|
1964
|
+
/>
|
|
1965
|
+
</div>
|
|
1966
|
+
<mcms-card-title>
|
|
1967
|
+
<span class="text-3xl font-bold">{{ totalRules() }}</span>
|
|
1968
|
+
</mcms-card-title>
|
|
1969
|
+
</mcms-card-header>
|
|
1970
|
+
</mcms-card>
|
|
1971
|
+
<mcms-card>
|
|
1972
|
+
<mcms-card-header>
|
|
1973
|
+
<mcms-card-description>Active</mcms-card-description>
|
|
1974
|
+
<mcms-card-title>
|
|
1975
|
+
<span class="text-3xl font-bold text-emerald-600">{{ activeRules() }}</span>
|
|
1976
|
+
</mcms-card-title>
|
|
1977
|
+
</mcms-card-header>
|
|
1978
|
+
</mcms-card>
|
|
1979
|
+
<mcms-card>
|
|
1980
|
+
<mcms-card-header>
|
|
1981
|
+
<mcms-card-description>Inactive</mcms-card-description>
|
|
1982
|
+
<mcms-card-title>
|
|
1983
|
+
<span class="text-3xl font-bold text-muted-foreground">{{ inactiveRules() }}</span>
|
|
1984
|
+
</mcms-card-title>
|
|
1985
|
+
</mcms-card-header>
|
|
1986
|
+
</mcms-card>
|
|
1987
|
+
</div>
|
|
1988
|
+
|
|
1989
|
+
<!-- Rules Table -->
|
|
1990
|
+
@if (loading() && rules().length === 0) {
|
|
1991
|
+
<div class="space-y-3">
|
|
1992
|
+
@for (i of [1, 2, 3]; track i) {
|
|
1993
|
+
<mcms-skeleton class="h-14 w-full" />
|
|
1994
|
+
}
|
|
1995
|
+
</div>
|
|
1996
|
+
} @else if (rules().length > 0) {
|
|
1997
|
+
<div class="border border-border rounded-lg overflow-hidden">
|
|
1998
|
+
<div class="overflow-x-auto">
|
|
1999
|
+
<table class="w-full text-sm" role="table">
|
|
2000
|
+
<thead>
|
|
2001
|
+
<tr class="border-b border-border bg-muted/50">
|
|
2002
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
2003
|
+
Name
|
|
2004
|
+
</th>
|
|
2005
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
2006
|
+
Selector
|
|
2007
|
+
</th>
|
|
2008
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
2009
|
+
Event
|
|
2010
|
+
</th>
|
|
2011
|
+
<th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
2012
|
+
URL Pattern
|
|
2013
|
+
</th>
|
|
2014
|
+
<th scope="col" class="px-4 py-3 text-center font-medium text-muted-foreground">
|
|
2015
|
+
Active
|
|
2016
|
+
</th>
|
|
2017
|
+
<th scope="col" class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
2018
|
+
Actions
|
|
2019
|
+
</th>
|
|
2020
|
+
</tr>
|
|
2021
|
+
</thead>
|
|
2022
|
+
<tbody>
|
|
2023
|
+
@for (rule of rules(); track rule.id) {
|
|
2024
|
+
<tr
|
|
2025
|
+
class="border-b border-border last:border-0 hover:bg-muted/30
|
|
2026
|
+
transition-colors"
|
|
2027
|
+
>
|
|
2028
|
+
<td class="px-4 py-3 font-medium text-foreground">
|
|
2029
|
+
{{ rule.name }}
|
|
2030
|
+
</td>
|
|
2031
|
+
<td
|
|
2032
|
+
class="px-4 py-3 font-mono text-xs text-muted-foreground max-w-48 truncate"
|
|
2033
|
+
[title]="rule.selector"
|
|
2034
|
+
>
|
|
2035
|
+
{{ rule.selector }}
|
|
2036
|
+
</td>
|
|
2037
|
+
<td class="px-4 py-3">
|
|
2038
|
+
<mcms-badge variant="outline">{{ rule.eventType }}</mcms-badge>
|
|
2039
|
+
<span class="text-muted-foreground ml-1">\u2192</span>
|
|
2040
|
+
<span class="ml-1 text-foreground">{{ rule.eventName }}</span>
|
|
2041
|
+
</td>
|
|
2042
|
+
<td class="px-4 py-3 text-muted-foreground">
|
|
2043
|
+
{{ rule.urlPattern }}
|
|
2044
|
+
</td>
|
|
2045
|
+
<td class="px-4 py-3 text-center">
|
|
2046
|
+
<mcms-switch
|
|
2047
|
+
[value]="rule.active"
|
|
2048
|
+
(valueChange)="toggleActive(rule)"
|
|
2049
|
+
ariaLabel="Toggle active"
|
|
2050
|
+
/>
|
|
2051
|
+
</td>
|
|
2052
|
+
<td class="px-4 py-3 text-right">
|
|
2053
|
+
<button
|
|
2054
|
+
mcms-button
|
|
2055
|
+
variant="ghost"
|
|
2056
|
+
size="icon"
|
|
2057
|
+
[mcmsDropdownTrigger]="ruleMenu"
|
|
2058
|
+
ariaLabel="Rule actions"
|
|
2059
|
+
>
|
|
2060
|
+
<ng-icon name="heroEllipsisVertical" size="16" aria-hidden="true" />
|
|
2061
|
+
</button>
|
|
2062
|
+
<ng-template #ruleMenu>
|
|
2063
|
+
<mcms-dropdown-menu>
|
|
2064
|
+
<button mcms-dropdown-item value="edit" (selected)="openEditDialog(rule)">
|
|
2065
|
+
Edit
|
|
2066
|
+
</button>
|
|
2067
|
+
<mcms-dropdown-separator />
|
|
2068
|
+
<button mcms-dropdown-item value="delete" (selected)="deleteRule(rule)">
|
|
2069
|
+
Delete
|
|
2070
|
+
</button>
|
|
2071
|
+
</mcms-dropdown-menu>
|
|
2072
|
+
</ng-template>
|
|
2073
|
+
</td>
|
|
2074
|
+
</tr>
|
|
2075
|
+
}
|
|
2076
|
+
</tbody>
|
|
2077
|
+
</table>
|
|
2078
|
+
</div>
|
|
2079
|
+
</div>
|
|
2080
|
+
} @else {
|
|
2081
|
+
<div class="flex flex-col items-center justify-center py-16 text-center">
|
|
2082
|
+
<ng-icon
|
|
2083
|
+
name="heroCursorArrowRays"
|
|
2084
|
+
class="text-muted-foreground mb-4"
|
|
2085
|
+
size="40"
|
|
2086
|
+
aria-hidden="true"
|
|
2087
|
+
/>
|
|
2088
|
+
<p class="text-foreground font-medium">No tracking rules defined</p>
|
|
2089
|
+
<p class="text-sm text-muted-foreground mt-1 mb-6">
|
|
2090
|
+
Create your first tracking rule to start tracking element interactions
|
|
2091
|
+
</p>
|
|
2092
|
+
<button mcms-button size="sm" (click)="openCreateDialog()">
|
|
2093
|
+
<ng-icon name="heroPlus" size="16" aria-hidden="true" />
|
|
2094
|
+
New Rule
|
|
2095
|
+
</button>
|
|
2096
|
+
</div>
|
|
2097
|
+
}
|
|
2098
|
+
`
|
|
2099
|
+
})
|
|
2100
|
+
], TrackingRulesPage);
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
// libs/plugins/analytics/src/lib/analytics-admin-routes.ts
|
|
2105
|
+
var analyticsAdminRoutes = [
|
|
2106
|
+
{
|
|
2107
|
+
path: "analytics",
|
|
2108
|
+
label: "Analytics",
|
|
2109
|
+
icon: "heroChartBarSquare",
|
|
2110
|
+
loadComponent: () => Promise.resolve().then(() => (init_analytics_dashboard_page(), analytics_dashboard_page_exports)).then((m) => m.AnalyticsDashboardPage),
|
|
2111
|
+
group: "Analytics"
|
|
2112
|
+
},
|
|
2113
|
+
{
|
|
2114
|
+
path: "analytics/content",
|
|
2115
|
+
label: "Content Perf.",
|
|
2116
|
+
icon: "heroDocumentText",
|
|
2117
|
+
loadComponent: () => Promise.resolve().then(() => (init_content_performance_page(), content_performance_page_exports)).then((m) => m.ContentPerformancePage),
|
|
2118
|
+
group: "Analytics"
|
|
2119
|
+
},
|
|
2120
|
+
{
|
|
2121
|
+
path: "analytics/tracking-rules",
|
|
2122
|
+
label: "Tracking Rules",
|
|
2123
|
+
icon: "heroCursorArrowRays",
|
|
2124
|
+
loadComponent: () => Promise.resolve().then(() => (init_tracking_rules_page(), tracking_rules_page_exports)).then((m) => m.TrackingRulesPage),
|
|
2125
|
+
group: "Analytics"
|
|
2126
|
+
}
|
|
2127
|
+
];
|
|
2128
|
+
export {
|
|
2129
|
+
analyticsAdminRoutes
|
|
2130
|
+
};
|