@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,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. {&quot;category&quot;: &quot;cta&quot;}'"
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
+ };