@momentumcms/plugins-analytics 0.3.0 → 0.4.1

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