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