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