@huloglobal/vendure-plugin-visitor-analytics 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,695 @@
1
+ import { Component, OnDestroy, OnInit, ChangeDetectorRef, NgZone } from '@angular/core';
2
+ import { HttpClient } from '@angular/common/http';
3
+ import { NotificationService } from '@vendure/admin-ui/core';
4
+
5
+ interface TopPage { url: string; title: string | null; views: number; uniqueVisitors: number; avgTimeMs: number; }
6
+ interface ExitPage { url: string; title: string | null; exits: number; }
7
+ interface FunnelStage { key: string; label: string; visitors: number; }
8
+ interface RecentVisitor {
9
+ visitorId: string; customerId: number | null;
10
+ firstSeenAt: string; lastSeenAt: string;
11
+ sessions: number; pageviews: number;
12
+ country: string | null; city: string | null;
13
+ browser: string | null; os: string | null; device: string | null;
14
+ }
15
+ interface JourneyEvent {
16
+ id: number; createdAt: string; type: string;
17
+ url: string; title: string | null; referrer: string | null;
18
+ timeOnPageMs: number | null; country: string | null;
19
+ ip: string | null; city: string | null; browser: string | null; os: string | null;
20
+ meta: string | null;
21
+ }
22
+ interface VisitorSession {
23
+ sessionId: string; startedAt: string; endedAt: string;
24
+ events: number; pageviews: number; timeMs: number; entryUrl: string | null;
25
+ }
26
+ interface VisitorProfile {
27
+ visitorId: string; customerId: number | null;
28
+ customer: { id: number; firstName: string; lastName: string; emailAddress: string } | null;
29
+ firstSeenAt: string; lastSeenAt: string;
30
+ totals: { sessions: number; pageviews: number; unloads: number; events: number; timeMs: number };
31
+ ip: string | null; ipHash: string | null;
32
+ userAgent: string | null;
33
+ browser: string | null; browserVersion: string | null;
34
+ os: string | null; osVersion: string | null; device: string | null;
35
+ acceptLanguage: string | null;
36
+ country: string | null; region: string | null; city: string | null; timezone: string | null;
37
+ channelId: number;
38
+ }
39
+
40
+ @Component({
41
+ selector: 'ees-visitors',
42
+ standalone: false,
43
+ template: `
44
+ <vdr-page-block>
45
+ <vdr-action-bar>
46
+ <vdr-ab-left><h2>Visitor journey</h2></vdr-ab-left>
47
+ <vdr-ab-right>
48
+ <span class="range">
49
+ Range:
50
+ <button class="btn btn-sm btn-link" *ngFor="let d of [7, 30, 90, 365]"
51
+ (click)="setDays(d)" [class.active]="days === d">{{ d }}d</button>
52
+ </span>
53
+ <button class="btn btn-link" (click)="loadAll()" [disabled]="loading">
54
+ <clr-icon shape="refresh"></clr-icon> Refresh
55
+ </button>
56
+ </vdr-ab-right>
57
+ </vdr-action-bar>
58
+ </vdr-page-block>
59
+
60
+ <vdr-page-block>
61
+ <div class="summary-row">
62
+ <div class="summary-card live-card">
63
+ <div class="num">
64
+ <span class="live-dot" [class.connected]="liveConnected"></span>
65
+ {{ liveCount | number }}
66
+ </div>
67
+ <div class="lbl">Live now <span class="live-meta" *ngIf="liveUpdatedAt">· refreshed {{ liveUpdatedAt | date:'HH:mm:ss' }}</span></div>
68
+ </div>
69
+ <div class="summary-card">
70
+ <div class="num">{{ summary.visitors | number }}</div>
71
+ <div class="lbl">Unique visitors</div>
72
+ </div>
73
+ <div class="summary-card">
74
+ <div class="num">{{ summary.sessions | number }}</div>
75
+ <div class="lbl">Sessions</div>
76
+ </div>
77
+ <div class="summary-card">
78
+ <div class="num">{{ summary.pageviews | number }}</div>
79
+ <div class="lbl">Page views</div>
80
+ </div>
81
+ <div class="summary-card">
82
+ <div class="num">{{ humanTime(summary.avgTimeMs) }}</div>
83
+ <div class="lbl">Avg time on page</div>
84
+ </div>
85
+ </div>
86
+
87
+ <div class="live-strip" *ngIf="liveRecent.length > 0">
88
+ <div class="live-strip-title">
89
+ Currently viewing
90
+ <span class="muted" *ngIf="!liveConnected">— connection lost, reconnecting…</span>
91
+ </div>
92
+ <table class="table table-compact">
93
+ <thead>
94
+ <tr>
95
+ <th>Visitor</th>
96
+ <th>URL</th>
97
+ <th>Country</th>
98
+ <th class="num-col">Last seen</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody>
102
+ <tr *ngFor="let v of liveRecent" class="clickable" (click)="openProfile(v.visitorId)">
103
+ <td class="mono">{{ v.visitorId | slice:0:10 }}…</td>
104
+ <td><span class="url">{{ v.url }}</span></td>
105
+ <td>{{ v.country || '—' }}</td>
106
+ <td class="num-col">{{ v.secondsAgo }}s ago</td>
107
+ </tr>
108
+ </tbody>
109
+ </table>
110
+ </div>
111
+ </vdr-page-block>
112
+
113
+ <vdr-page-block>
114
+ <div class="card">
115
+ <div class="card-block">
116
+ <h3 class="card-title">Funnel <span class="muted">(last {{ days }} days)</span></h3>
117
+ <div *ngIf="funnel.length === 0" class="muted pad">No data yet.</div>
118
+ <div class="funnel" *ngIf="funnel.length > 0">
119
+ <div class="funnel-row" *ngFor="let s of funnel">
120
+ <div class="funnel-label">{{ s.label }}</div>
121
+ <div class="funnel-bar">
122
+ <div class="funnel-bar-fill" [style.width.%]="funnelPct(s)"></div>
123
+ </div>
124
+ <div class="funnel-num">
125
+ <strong>{{ s.visitors | number }}</strong>
126
+ <span class="muted" *ngIf="s !== funnel[0]"> ({{ funnelPct(s) | number:'1.0-1' }}%)</span>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </vdr-page-block>
133
+
134
+ <vdr-page-block>
135
+ <div class="two-col">
136
+ <div class="card">
137
+ <div class="card-block">
138
+ <h3 class="card-title">
139
+ Top pages
140
+ <span class="muted">{{ topTotal | number }} total</span>
141
+ </h3>
142
+ <div *ngIf="topPages.length === 0" class="muted pad">No data.</div>
143
+ <table class="table table-compact" *ngIf="topPages.length > 0">
144
+ <thead>
145
+ <tr><th>URL</th><th class="num-col">Views</th><th class="num-col">Unique</th><th class="num-col">Avg time</th></tr>
146
+ </thead>
147
+ <tbody>
148
+ <tr *ngFor="let p of topPages">
149
+ <td>
150
+ <div class="url">{{ p.url }}</div>
151
+ <div class="help-text" *ngIf="p.title">{{ p.title }}</div>
152
+ </td>
153
+ <td class="num-col">{{ p.views | number }}</td>
154
+ <td class="num-col">{{ p.uniqueVisitors | number }}</td>
155
+ <td class="num-col">{{ humanTime(p.avgTimeMs) }}</td>
156
+ </tr>
157
+ </tbody>
158
+ </table>
159
+ <div class="pager" *ngIf="topTotal > topTake">
160
+ <button class="btn btn-sm" (click)="topPrev()" [disabled]="topSkip === 0">‹ Prev</button>
161
+ <span class="muted">{{ topSkip + 1 }}–{{ topSkip + topPages.length }} of {{ topTotal }}</span>
162
+ <button class="btn btn-sm" (click)="topNext()" [disabled]="topSkip + topTake >= topTotal">Next ›</button>
163
+ </div>
164
+ </div>
165
+ </div>
166
+
167
+ <div class="card">
168
+ <div class="card-block">
169
+ <h3 class="card-title">
170
+ Exit pages
171
+ <span class="muted">{{ exitTotal | number }} total</span>
172
+ </h3>
173
+ <div *ngIf="exitPages.length === 0" class="muted pad">No data.</div>
174
+ <table class="table table-compact" *ngIf="exitPages.length > 0">
175
+ <thead>
176
+ <tr><th>URL</th><th class="num-col">Exits</th></tr>
177
+ </thead>
178
+ <tbody>
179
+ <tr *ngFor="let p of exitPages">
180
+ <td>
181
+ <div class="url">{{ p.url }}</div>
182
+ <div class="help-text" *ngIf="p.title">{{ p.title }}</div>
183
+ </td>
184
+ <td class="num-col">{{ p.exits | number }}</td>
185
+ </tr>
186
+ </tbody>
187
+ </table>
188
+ <div class="pager" *ngIf="exitTotal > exitTake">
189
+ <button class="btn btn-sm" (click)="exitPrev()" [disabled]="exitSkip === 0">‹ Prev</button>
190
+ <span class="muted">{{ exitSkip + 1 }}–{{ exitSkip + exitPages.length }} of {{ exitTotal }}</span>
191
+ <button class="btn btn-sm" (click)="exitNext()" [disabled]="exitSkip + exitTake >= exitTotal">Next ›</button>
192
+ </div>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </vdr-page-block>
197
+
198
+ <vdr-page-block>
199
+ <div class="card">
200
+ <div class="card-block">
201
+ <h3 class="card-title">
202
+ Visitors
203
+ <span class="muted">{{ recentTotal | number }} total · click a row for the full profile</span>
204
+ </h3>
205
+ <div *ngIf="recent.length === 0" class="muted pad">No visitors in this range.</div>
206
+ <table class="table table-compact" *ngIf="recent.length > 0">
207
+ <thead>
208
+ <tr>
209
+ <th>Visitor</th>
210
+ <th>Customer</th>
211
+ <th>Location</th>
212
+ <th>Browser · OS · device</th>
213
+ <th class="num-col">Sessions</th>
214
+ <th class="num-col">Pageviews</th>
215
+ <th>Last seen</th>
216
+ <th></th>
217
+ </tr>
218
+ </thead>
219
+ <tbody>
220
+ <tr *ngFor="let v of recent" class="clickable" (click)="openProfile(v.visitorId)">
221
+ <td class="mono">{{ v.visitorId | slice:0:10 }}…</td>
222
+ <td>
223
+ <a *ngIf="v.customerId" [routerLink]="['/customers', v.customerId]" (click)="$event.stopPropagation()">
224
+ #{{ v.customerId }}
225
+ </a>
226
+ <span *ngIf="!v.customerId" class="muted">guest</span>
227
+ </td>
228
+ <td>
229
+ <span *ngIf="v.country">{{ v.country }}<span *ngIf="v.city"> · {{ v.city }}</span></span>
230
+ <span *ngIf="!v.country" class="muted">—</span>
231
+ </td>
232
+ <td>
233
+ <span *ngIf="v.browser">{{ v.browser }}</span>
234
+ <span *ngIf="v.os" class="muted"> · {{ v.os }}</span>
235
+ <span *ngIf="v.device" class="muted"> · {{ v.device }}</span>
236
+ </td>
237
+ <td class="num-col">{{ v.sessions | number }}</td>
238
+ <td class="num-col">{{ v.pageviews | number }}</td>
239
+ <td>{{ v.lastSeenAt | date:'short' }}</td>
240
+ <td>
241
+ <button class="btn btn-sm btn-link" (click)="openProfile(v.visitorId); $event.stopPropagation()">
242
+ <clr-icon shape="eye"></clr-icon> View
243
+ </button>
244
+ </td>
245
+ </tr>
246
+ </tbody>
247
+ </table>
248
+ <div class="pager" *ngIf="recentTotal > recentTake">
249
+ <button class="btn btn-sm" (click)="recentPrev()" [disabled]="recentSkip === 0">‹ Prev</button>
250
+ <span class="muted">{{ recentSkip + 1 }}–{{ recentSkip + recent.length }} of {{ recentTotal }}</span>
251
+ <button class="btn btn-sm" (click)="recentNext()" [disabled]="recentSkip + recentTake >= recentTotal">Next ›</button>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ </vdr-page-block>
256
+
257
+ <!-- Visitor profile drawer -->
258
+ <div class="drawer-overlay" *ngIf="selectedProfile || profileLoading" (click)="closeProfile()">
259
+ <div class="drawer" (click)="$event.stopPropagation()">
260
+ <div class="drawer-head">
261
+ <h3>Visitor profile</h3>
262
+ <button class="btn btn-link" (click)="closeProfile()">
263
+ <clr-icon shape="times"></clr-icon> Close
264
+ </button>
265
+ </div>
266
+ <div class="drawer-body" *ngIf="profileLoading">Loading…</div>
267
+ <div class="drawer-body" *ngIf="selectedProfile">
268
+ <div class="profile-grid">
269
+ <div>
270
+ <div class="lbl">Visitor ID</div>
271
+ <div class="mono small">{{ selectedProfile.visitorId }}</div>
272
+ </div>
273
+ <div>
274
+ <div class="lbl">First seen</div>
275
+ <div>{{ selectedProfile.firstSeenAt | date:'medium' }}</div>
276
+ </div>
277
+ <div>
278
+ <div class="lbl">Last seen</div>
279
+ <div>{{ selectedProfile.lastSeenAt | date:'medium' }}</div>
280
+ </div>
281
+ <div>
282
+ <div class="lbl">Customer</div>
283
+ <div *ngIf="selectedProfile.customer">
284
+ <a [routerLink]="['/customers', selectedProfile.customer.id]">
285
+ {{ selectedProfile.customer.firstName }} {{ selectedProfile.customer.lastName }}
286
+ </a>
287
+ <div class="help-text">{{ selectedProfile.customer.emailAddress }}</div>
288
+ </div>
289
+ <div *ngIf="!selectedProfile.customer" class="muted">Guest — never signed in</div>
290
+ </div>
291
+ </div>
292
+
293
+ <h4 class="section">Totals</h4>
294
+ <div class="profile-grid four">
295
+ <div><div class="lbl">Sessions</div><div class="big">{{ selectedProfile.totals.sessions }}</div></div>
296
+ <div><div class="lbl">Pageviews</div><div class="big">{{ selectedProfile.totals.pageviews }}</div></div>
297
+ <div><div class="lbl">Custom events</div><div class="big">{{ selectedProfile.totals.events }}</div></div>
298
+ <div><div class="lbl">Total time</div><div class="big">{{ humanTime(selectedProfile.totals.timeMs) }}</div></div>
299
+ </div>
300
+
301
+ <h4 class="section">Network</h4>
302
+ <div class="profile-grid">
303
+ <div>
304
+ <div class="lbl">IP address</div>
305
+ <div class="mono small">{{ selectedProfile.ip || '—' }}</div>
306
+ <div class="help-text">Hash: {{ selectedProfile.ipHash || '—' }}</div>
307
+ </div>
308
+ <div>
309
+ <div class="lbl">Country / Region / City</div>
310
+ <div>
311
+ <span *ngIf="selectedProfile.country">{{ selectedProfile.country }}</span>
312
+ <span *ngIf="selectedProfile.region"> / {{ selectedProfile.region }}</span>
313
+ <span *ngIf="selectedProfile.city"> / {{ selectedProfile.city }}</span>
314
+ <span *ngIf="!selectedProfile.country" class="muted">—</span>
315
+ </div>
316
+ </div>
317
+ <div>
318
+ <div class="lbl">Timezone</div>
319
+ <div>{{ selectedProfile.timezone || '—' }}</div>
320
+ </div>
321
+ <div>
322
+ <div class="lbl">Channel</div>
323
+ <div>#{{ selectedProfile.channelId }}</div>
324
+ </div>
325
+ </div>
326
+
327
+ <h4 class="section">Device</h4>
328
+ <div class="profile-grid">
329
+ <div>
330
+ <div class="lbl">Browser</div>
331
+ <div>{{ selectedProfile.browser || '—' }}<span *ngIf="selectedProfile.browserVersion"> {{ selectedProfile.browserVersion }}</span></div>
332
+ </div>
333
+ <div>
334
+ <div class="lbl">OS</div>
335
+ <div>{{ selectedProfile.os || '—' }}<span *ngIf="selectedProfile.osVersion"> {{ selectedProfile.osVersion }}</span></div>
336
+ </div>
337
+ <div>
338
+ <div class="lbl">Device type</div>
339
+ <div>{{ selectedProfile.device || '—' }}</div>
340
+ </div>
341
+ <div>
342
+ <div class="lbl">Accept-Language</div>
343
+ <div class="mono small">{{ selectedProfile.acceptLanguage || '—' }}</div>
344
+ </div>
345
+ </div>
346
+
347
+ <h4 class="section">User agent</h4>
348
+ <div class="ua-box">{{ selectedProfile.userAgent || '—' }}</div>
349
+
350
+ <h4 class="section">Sessions ({{ selectedSessions.length }})</h4>
351
+ <table class="table table-compact" *ngIf="selectedSessions.length > 0">
352
+ <thead>
353
+ <tr><th>Session</th><th>Started</th><th>Entry</th><th class="num-col">Events</th><th class="num-col">Pageviews</th><th class="num-col">Time</th></tr>
354
+ </thead>
355
+ <tbody>
356
+ <tr *ngFor="let s of selectedSessions">
357
+ <td class="mono small">{{ s.sessionId | slice:0:10 }}…</td>
358
+ <td class="small">{{ s.startedAt | date:'short' }}</td>
359
+ <td class="mono small">{{ s.entryUrl || '—' }}</td>
360
+ <td class="num-col">{{ s.events }}</td>
361
+ <td class="num-col">{{ s.pageviews }}</td>
362
+ <td class="num-col">{{ humanTime(s.timeMs) }}</td>
363
+ </tr>
364
+ </tbody>
365
+ </table>
366
+
367
+ <h4 class="section">Journey (every event)</h4>
368
+ <ol class="journey" *ngIf="journey.length > 0">
369
+ <li *ngFor="let e of journey" [ngClass]="'event-' + e.type">
370
+ <span class="event-time">{{ e.createdAt | date:'short' }}</span>
371
+ <span class="event-type">{{ e.type }}</span>
372
+ <span class="event-url">
373
+ {{ e.url }}
374
+ <span class="muted" *ngIf="e.title"> · {{ e.title }}</span>
375
+ </span>
376
+ <span class="event-time-on" *ngIf="e.timeOnPageMs">{{ humanTime(e.timeOnPageMs) }}</span>
377
+ </li>
378
+ </ol>
379
+ <div *ngIf="journey.length === 0" class="muted pad">No events.</div>
380
+ </div>
381
+ </div>
382
+ </div>
383
+ `,
384
+ styles: [`
385
+ :host { color: var(--color-text-100, inherit); display: block; }
386
+ .range { font-size: 12px; color: var(--color-component-color-300); margin-right: 8px; }
387
+ .range .btn { padding: 2px 8px; min-width: 0; }
388
+ .range .btn.active { font-weight: 700; color: var(--color-primary-500, #1d4ed8); }
389
+ .summary-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
390
+ .summary-card {
391
+ flex: 1; min-width: 130px; padding: 16px 20px;
392
+ border: 1px solid var(--color-component-border-200);
393
+ border-radius: 6px;
394
+ background: var(--color-component-bg-100);
395
+ }
396
+ .summary-card .num { font-size: 24px; font-weight: 700; }
397
+ .summary-card .lbl { font-size: 11px; color: var(--color-component-color-300); margin-top: 4px; }
398
+
399
+ .live-card { border-left: 3px solid #ef4444; }
400
+ .live-card .num { display: inline-flex; align-items: center; gap: 8px; }
401
+ .live-dot {
402
+ display: inline-block; width: 10px; height: 10px; border-radius: 50%;
403
+ background: #9ca3af; box-shadow: 0 0 0 0 rgba(156,163,175,.4);
404
+ }
405
+ .live-dot.connected {
406
+ background: #ef4444;
407
+ animation: live-pulse 1.6s ease-in-out infinite;
408
+ }
409
+ .live-meta { color: var(--color-component-color-300); font-size: 10px; font-weight: 400; }
410
+ @keyframes live-pulse {
411
+ 0% { box-shadow: 0 0 0 0 rgba(239,68,68,.6); }
412
+ 70% { box-shadow: 0 0 0 10px rgba(239,68,68,0); }
413
+ 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
414
+ }
415
+
416
+ .live-strip {
417
+ border: 1px solid var(--color-component-border-200);
418
+ border-radius: 6px;
419
+ background: var(--color-component-bg-100);
420
+ padding: 12px;
421
+ }
422
+ .live-strip-title { font-size: 12px; font-weight: 600; margin-bottom: 8px; }
423
+ .card-title { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
424
+ .card-title .muted { color: var(--color-component-color-300); font-weight: 400; font-size: 12px; margin-left: 8px; }
425
+ .muted { color: var(--color-component-color-300); }
426
+ .small { font-size: 11px; }
427
+ .pad { padding: 24px; text-align: center; font-size: 13px; }
428
+
429
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
430
+
431
+ .table-compact th, .table-compact td { font-size: 12px; }
432
+ .num-col { text-align: right; white-space: nowrap; }
433
+ .url { font-family: var(--clr-font-family-monospace, monospace); font-size: 11px; word-break: break-all; }
434
+ .help-text { font-size: 11px; color: var(--color-component-color-300); margin-top: 2px; }
435
+ .mono { font-family: var(--clr-font-family-monospace, monospace); }
436
+ tr.clickable { cursor: pointer; }
437
+ tr.clickable:hover { background: var(--color-component-bg-200); }
438
+
439
+ .funnel { display: flex; flex-direction: column; gap: 8px; }
440
+ .funnel-row { display: grid; grid-template-columns: 200px 1fr 140px; gap: 12px; align-items: center; }
441
+ .funnel-label { font-size: 13px; font-weight: 500; }
442
+ .funnel-bar {
443
+ height: 24px; background: var(--color-component-bg-200);
444
+ border-radius: 4px; overflow: hidden;
445
+ border: 1px solid var(--color-component-border-200);
446
+ }
447
+ .funnel-bar-fill { height: 100%; background: linear-gradient(90deg, #1d4ed8, #3b82f6); }
448
+ .funnel-num { font-size: 13px; }
449
+
450
+ .pager {
451
+ display: flex; align-items: center; justify-content: flex-end; gap: 8px;
452
+ padding: 10px 0; font-size: 12px;
453
+ }
454
+
455
+ /* Profile drawer */
456
+ .drawer-overlay {
457
+ position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1000;
458
+ display: flex; justify-content: flex-end;
459
+ }
460
+ .drawer {
461
+ background: var(--color-component-bg-100);
462
+ width: 720px; max-width: 92vw; height: 100vh;
463
+ overflow-y: auto; box-shadow: -4px 0 16px rgba(0,0,0,.18);
464
+ display: flex; flex-direction: column;
465
+ }
466
+ .drawer-head {
467
+ position: sticky; top: 0; background: var(--color-component-bg-100);
468
+ display: flex; justify-content: space-between; align-items: center;
469
+ padding: 16px 24px; border-bottom: 1px solid var(--color-component-border-200);
470
+ z-index: 1;
471
+ }
472
+ .drawer-body { padding: 18px 24px 80px; }
473
+ .section { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; margin: 22px 0 8px; color: var(--color-component-color-300); }
474
+ .profile-grid {
475
+ display: grid; grid-template-columns: 1fr 1fr; gap: 14px 22px;
476
+ }
477
+ .profile-grid.four { grid-template-columns: repeat(4, 1fr); }
478
+ .lbl { font-size: 11px; color: var(--color-component-color-300); text-transform: uppercase; margin-bottom: 2px; }
479
+ .big { font-size: 18px; font-weight: 700; }
480
+ .ua-box {
481
+ font-family: var(--clr-font-family-monospace, monospace);
482
+ font-size: 11px; padding: 10px;
483
+ background: var(--color-component-bg-200);
484
+ border: 1px solid var(--color-component-border-200);
485
+ border-radius: 4px;
486
+ word-break: break-all;
487
+ }
488
+
489
+ .journey { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
490
+ .journey li {
491
+ display: grid; grid-template-columns: 140px 70px 1fr auto; gap: 8px;
492
+ padding: 6px 10px; border-radius: 4px; font-size: 12px;
493
+ background: var(--color-component-bg-200);
494
+ border-left: 3px solid var(--color-component-border-200);
495
+ }
496
+ .journey li.event-pageview { border-left-color: #3b82f6; }
497
+ .journey li.event-unload { border-left-color: #6b7280; }
498
+ .journey li.event-event { border-left-color: #10b981; }
499
+ .event-time { color: var(--color-component-color-300); font-family: var(--clr-font-family-monospace, monospace); }
500
+ .event-type { font-weight: 600; text-transform: uppercase; font-size: 10px; }
501
+ .event-url { font-family: var(--clr-font-family-monospace, monospace); word-break: break-all; }
502
+ .event-time-on { color: var(--color-component-color-300); }
503
+ `],
504
+ })
505
+ export class VisitorsComponent implements OnInit, OnDestroy {
506
+ loading = false;
507
+ days = 30;
508
+
509
+ summary = { visitors: 0, sessions: 0, pageviews: 0, avgTimeMs: 0 };
510
+ funnel: FunnelStage[] = [];
511
+
512
+ // Live-now SSE state.
513
+ private liveSource: EventSource | null = null;
514
+ liveCount = 0;
515
+ liveConnected = false;
516
+ liveUpdatedAt: Date | null = null;
517
+ liveRecent: Array<{ visitorId: string; url: string; country: string | null; secondsAgo: number }> = [];
518
+
519
+ topPages: TopPage[] = [];
520
+ topTotal = 0;
521
+ topTake = 25;
522
+ topSkip = 0;
523
+
524
+ exitPages: ExitPage[] = [];
525
+ exitTotal = 0;
526
+ exitTake = 25;
527
+ exitSkip = 0;
528
+
529
+ recent: RecentVisitor[] = [];
530
+ recentTotal = 0;
531
+ recentTake = 25;
532
+ recentSkip = 0;
533
+
534
+ // Drawer
535
+ selectedProfile: VisitorProfile | null = null;
536
+ selectedSessions: VisitorSession[] = [];
537
+ journey: JourneyEvent[] = [];
538
+ profileLoading = false;
539
+
540
+ constructor(
541
+ private http: HttpClient,
542
+ private notify: NotificationService,
543
+ private cdr: ChangeDetectorRef,
544
+ private zone: NgZone,
545
+ ) {}
546
+
547
+ ngOnInit() {
548
+ this.loadAll();
549
+ this.connectLive();
550
+ }
551
+
552
+ ngOnDestroy() {
553
+ this.disconnectLive();
554
+ }
555
+
556
+ /** Open the live-now SSE stream. Angular's zone has no idea events
557
+ * are arriving from EventSource, so we hop back inside it before
558
+ * mutating state — otherwise the view won't refresh. */
559
+ private connectLive(): void {
560
+ if (typeof EventSource === 'undefined') return;
561
+ try {
562
+ this.liveSource = new EventSource('/ees/visitors/live', { withCredentials: true } as any);
563
+ } catch {
564
+ return;
565
+ }
566
+ this.liveSource.onopen = () => this.zone.run(() => {
567
+ this.liveConnected = true;
568
+ this.cdr.markForCheck();
569
+ });
570
+ this.liveSource.onerror = () => this.zone.run(() => {
571
+ this.liveConnected = false;
572
+ this.cdr.markForCheck();
573
+ });
574
+ this.liveSource.onmessage = (ev) => this.zone.run(() => {
575
+ try {
576
+ const data = JSON.parse(ev.data);
577
+ this.liveCount = data.activeCount || 0;
578
+ this.liveRecent = Array.isArray(data.recent) ? data.recent : [];
579
+ this.liveUpdatedAt = new Date(data.ts || Date.now());
580
+ this.liveConnected = true;
581
+ this.cdr.markForCheck();
582
+ } catch {
583
+ // ignore malformed frame
584
+ }
585
+ });
586
+ }
587
+
588
+ private disconnectLive(): void {
589
+ if (this.liveSource) {
590
+ this.liveSource.close();
591
+ this.liveSource = null;
592
+ }
593
+ }
594
+
595
+ setDays(d: number) {
596
+ this.days = d;
597
+ this.topSkip = 0; this.exitSkip = 0; this.recentSkip = 0;
598
+ this.loadAll();
599
+ }
600
+
601
+ loadAll() {
602
+ this.loading = true;
603
+ Promise.all([
604
+ this.http.get<any>(`/ees/visitors/summary?days=${this.days}`).toPromise(),
605
+ this.http.get<any>(`/ees/visitors/funnel?days=${this.days}`).toPromise(),
606
+ this.fetchTop(),
607
+ this.fetchExit(),
608
+ this.fetchRecent(),
609
+ ]).then(([summaryRes, funnelRes]) => {
610
+ this.summary = summaryRes?.totals || this.summary;
611
+ this.funnel = funnelRes?.stages || [];
612
+ this.loading = false;
613
+ this.cdr.markForCheck();
614
+ }).catch(() => {
615
+ this.loading = false;
616
+ this.notify.error('Failed to load visitor data');
617
+ });
618
+ }
619
+
620
+ fetchTop(): Promise<void> {
621
+ return this.http.get<any>(`/ees/visitors/top-pages?days=${this.days}&take=${this.topTake}&skip=${this.topSkip}`).toPromise()
622
+ .then((res: any) => {
623
+ this.topPages = res?.pages || [];
624
+ this.topTotal = res?.total || 0;
625
+ this.cdr.markForCheck();
626
+ });
627
+ }
628
+ topPrev() { this.topSkip = Math.max(0, this.topSkip - this.topTake); this.fetchTop(); }
629
+ topNext() { this.topSkip += this.topTake; this.fetchTop(); }
630
+
631
+ fetchExit(): Promise<void> {
632
+ return this.http.get<any>(`/ees/visitors/exit-pages?days=${this.days}&take=${this.exitTake}&skip=${this.exitSkip}`).toPromise()
633
+ .then((res: any) => {
634
+ this.exitPages = res?.exitPages || [];
635
+ this.exitTotal = res?.total || 0;
636
+ this.cdr.markForCheck();
637
+ });
638
+ }
639
+ exitPrev() { this.exitSkip = Math.max(0, this.exitSkip - this.exitTake); this.fetchExit(); }
640
+ exitNext() { this.exitSkip += this.exitTake; this.fetchExit(); }
641
+
642
+ fetchRecent(): Promise<void> {
643
+ return this.http.get<any>(`/ees/visitors/recent?days=${this.days}&take=${this.recentTake}&skip=${this.recentSkip}`).toPromise()
644
+ .then((res: any) => {
645
+ this.recent = res?.visitors || [];
646
+ this.recentTotal = res?.total || 0;
647
+ this.cdr.markForCheck();
648
+ });
649
+ }
650
+ recentPrev() { this.recentSkip = Math.max(0, this.recentSkip - this.recentTake); this.fetchRecent(); }
651
+ recentNext() { this.recentSkip += this.recentTake; this.fetchRecent(); }
652
+
653
+ funnelPct(s: FunnelStage): number {
654
+ const base = this.funnel[0]?.visitors || 1;
655
+ return base ? (s.visitors / base) * 100 : 0;
656
+ }
657
+
658
+ openProfile(visitorId: string) {
659
+ this.profileLoading = true;
660
+ this.selectedProfile = null;
661
+ this.selectedSessions = [];
662
+ this.journey = [];
663
+ Promise.all([
664
+ this.http.get<any>(`/ees/visitors/profile/${visitorId}`).toPromise(),
665
+ this.http.get<any>(`/ees/visitors/journey/${visitorId}`).toPromise(),
666
+ ]).then(([p, j]) => {
667
+ this.selectedProfile = p?.visitor || null;
668
+ this.selectedSessions = p?.sessions || [];
669
+ this.journey = j?.events || [];
670
+ this.profileLoading = false;
671
+ this.cdr.markForCheck();
672
+ }).catch(() => {
673
+ this.profileLoading = false;
674
+ this.notify.error('Failed to load visitor profile');
675
+ });
676
+ }
677
+
678
+ closeProfile() {
679
+ this.selectedProfile = null;
680
+ this.selectedSessions = [];
681
+ this.journey = [];
682
+ }
683
+
684
+ humanTime(ms: number): string {
685
+ if (!ms || ms < 1000) return ms ? '<1s' : '—';
686
+ const s = Math.floor(ms / 1000);
687
+ if (s < 60) return `${s}s`;
688
+ const m = Math.floor(s / 60);
689
+ const rs = s % 60;
690
+ if (m < 60) return `${m}m ${rs}s`;
691
+ const h = Math.floor(m / 60);
692
+ const rm = m % 60;
693
+ return `${h}h ${rm}m`;
694
+ }
695
+ }