@huloglobal/vendure-plugin-visitor-analytics 0.9.1 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huloglobal/vendure-plugin-visitor-analytics",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Full-funnel visitor analytics for Vendure storefronts — pageviews, time-on-page, session journey, exit pages, funnel drop-off, and per-visitor profile drawer with parsed user-agent + MaxMind GeoLite2 enrichment. Auto-issues visitor + session cookies on first request; logs guest and signed-in events against the same visitor id so the journey survives login.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Wayne Garrison <wayne@garrison.me.uk>",
@@ -39,7 +39,7 @@
39
39
  "devDependencies": {
40
40
  "@huloglobal/vendure-licence-sdk": "^0.4.0",
41
41
  "@nestjs/common": "^10.0.0",
42
- "@vendure/core": "^3.6.0",
42
+ "@vendure/core": "^3.7.1",
43
43
  "typeorm": "^0.3.0",
44
44
  "typescript": "^5.4.0"
45
45
  },
@@ -179,10 +179,75 @@ interface VisitorProfile {
179
179
  </div>
180
180
  </vdr-page-block>
181
181
 
182
+ <!-- ── Daily activity chart — line chart over the summary's daily
183
+ series. Series toggles are multi-select (identity colors are
184
+ fixed per series and never repaint on toggle); state persists
185
+ in localStorage alongside the section collapse prefs. -->
182
186
  <vdr-page-block>
183
187
  <div class="card">
184
188
  <div class="card-block">
185
- <h3 class="card-title">Funnel <span class="muted">(last {{ days }} days)</span></h3>
189
+ <button type="button" class="collapse-head" (click)="toggleSection('chart')" [attr.aria-expanded]="sectionOpen['chart']">
190
+ <h3 class="card-title">Daily activity <span class="muted">(last {{ days }} days)</span></h3>
191
+ <clr-icon shape="angle" [attr.dir]="sectionOpen['chart'] ? 'down' : 'right'"></clr-icon>
192
+ </button>
193
+ <ng-container *ngIf="sectionOpen['chart']">
194
+ <div class="series-toggles" role="group" aria-label="Chart series">
195
+ <button *ngFor="let sd of seriesDefs" type="button"
196
+ class="series-pill" [class.on]="seriesOn[sd.key]"
197
+ [attr.aria-pressed]="seriesOn[sd.key]"
198
+ (click)="toggleSeries(sd.key)">
199
+ <span class="series-chip" [style.background]="seriesOn[sd.key] ? sd.color : 'transparent'" [style.borderColor]="sd.color"></span>
200
+ {{ sd.label }}
201
+ </button>
202
+ </div>
203
+ <div *ngIf="!chart" class="muted pad">No data in this range.</div>
204
+ <div class="chart-wrap" *ngIf="chart">
205
+ <svg #dailySvg class="daily-chart" viewBox="0 0 800 240" preserveAspectRatio="none" role="img"
206
+ [attr.aria-label]="'Daily activity chart, ' + days + ' days'"
207
+ (mousemove)="onChartMove($event, dailySvg)" (mouseleave)="onChartLeave()">
208
+ <!-- recessive gridlines + y labels -->
209
+ <g *ngFor="let t of chart.ticks">
210
+ <line [attr.x1]="chart.L" [attr.x2]="chart.R" [attr.y1]="t.y" [attr.y2]="t.y" class="gridline"/>
211
+ <text [attr.x]="chart.L - 6" [attr.y]="t.y + 3" class="axis-label" text-anchor="end">{{ t.label }}</text>
212
+ </g>
213
+ <!-- x labels -->
214
+ <text *ngFor="let xl of chart.xlabels" [attr.x]="xl.x" y="236" class="axis-label" text-anchor="middle">{{ xl.label }}</text>
215
+ <!-- series lines: 2px, color fixed per series -->
216
+ <polyline *ngFor="let ln of chart.lines" [attr.points]="ln.points"
217
+ fill="none" [attr.stroke]="ln.color" stroke-width="2"
218
+ stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
219
+ <!-- hover crosshair + markers -->
220
+ <g *ngIf="hoverIdx !== null && chart.xs[hoverIdx] !== undefined">
221
+ <line [attr.x1]="chart.xs[hoverIdx]" [attr.x2]="chart.xs[hoverIdx]" y1="10" y2="216" class="crosshair"/>
222
+ <circle *ngFor="let ln of chart.lines" [attr.cx]="chart.xs[hoverIdx]" [attr.cy]="ln.ys[hoverIdx]"
223
+ r="4" [attr.fill]="ln.color" class="hover-dot"/>
224
+ </g>
225
+ </svg>
226
+ <!-- tooltip: text tokens carry the values; colored chips carry identity -->
227
+ <div class="chart-tip" *ngIf="hoverIdx !== null && chart.days[hoverIdx]"
228
+ [style.left.%]="(chart.xs[hoverIdx] / 800) * 100"
229
+ [class.flip]="chart.xs[hoverIdx] > 560">
230
+ <div class="tip-date">{{ chart.days[hoverIdx].label }}</div>
231
+ <div class="tip-row" *ngFor="let ln of chart.lines">
232
+ <span class="series-chip" [style.background]="ln.color"></span>
233
+ <span class="tip-name">{{ ln.label }}</span>
234
+ <span class="tip-val">{{ chart.days[hoverIdx].values[ln.key] | number }}</span>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ </ng-container>
239
+ </div>
240
+ </div>
241
+ </vdr-page-block>
242
+
243
+ <vdr-page-block>
244
+ <div class="card">
245
+ <div class="card-block">
246
+ <button type="button" class="collapse-head" (click)="toggleSection('funnel')" [attr.aria-expanded]="sectionOpen['funnel']">
247
+ <h3 class="card-title">Funnel <span class="muted">(last {{ days }} days)</span></h3>
248
+ <clr-icon shape="angle" [attr.dir]="sectionOpen['funnel'] ? 'down' : 'right'"></clr-icon>
249
+ </button>
250
+ <ng-container *ngIf="sectionOpen['funnel']">
186
251
  <div *ngIf="funnel.length === 0" class="muted pad">No data yet.</div>
187
252
  <div class="funnel" *ngIf="funnel.length > 0">
188
253
  <div class="funnel-row" *ngFor="let s of funnel">
@@ -196,6 +261,7 @@ interface VisitorProfile {
196
261
  </div>
197
262
  </div>
198
263
  </div>
264
+ </ng-container>
199
265
  </div>
200
266
  </div>
201
267
  </vdr-page-block>
@@ -204,10 +270,14 @@ interface VisitorProfile {
204
270
  <div class="two-col">
205
271
  <div class="card">
206
272
  <div class="card-block">
207
- <h3 class="card-title">
208
- Top pages
209
- <span class="muted">{{ topTotal | number }} total</span>
210
- </h3>
273
+ <button type="button" class="collapse-head" (click)="toggleSection('top')" [attr.aria-expanded]="sectionOpen['top']">
274
+ <h3 class="card-title">
275
+ Top pages
276
+ <span class="muted">{{ topTotal | number }} total</span>
277
+ </h3>
278
+ <clr-icon shape="angle" [attr.dir]="sectionOpen['top'] ? 'down' : 'right'"></clr-icon>
279
+ </button>
280
+ <ng-container *ngIf="sectionOpen['top']">
211
281
  <div *ngIf="topPages.length === 0" class="muted pad">No data.</div>
212
282
  <table class="table table-compact" *ngIf="topPages.length > 0">
213
283
  <thead>
@@ -230,15 +300,20 @@ interface VisitorProfile {
230
300
  <span class="muted">{{ topSkip + 1 }}–{{ topSkip + topPages.length }} of {{ topTotal }}</span>
231
301
  <button class="btn btn-sm" (click)="topNext()" [disabled]="topSkip + topTake >= topTotal">Next ›</button>
232
302
  </div>
303
+ </ng-container>
233
304
  </div>
234
305
  </div>
235
306
 
236
307
  <div class="card">
237
308
  <div class="card-block">
238
- <h3 class="card-title">
239
- Exit pages
240
- <span class="muted">{{ exitTotal | number }} total</span>
241
- </h3>
309
+ <button type="button" class="collapse-head" (click)="toggleSection('exit')" [attr.aria-expanded]="sectionOpen['exit']">
310
+ <h3 class="card-title">
311
+ Exit pages
312
+ <span class="muted">{{ exitTotal | number }} total</span>
313
+ </h3>
314
+ <clr-icon shape="angle" [attr.dir]="sectionOpen['exit'] ? 'down' : 'right'"></clr-icon>
315
+ </button>
316
+ <ng-container *ngIf="sectionOpen['exit']">
242
317
  <div *ngIf="exitPages.length === 0" class="muted pad">No data.</div>
243
318
  <table class="table table-compact" *ngIf="exitPages.length > 0">
244
319
  <thead>
@@ -259,6 +334,7 @@ interface VisitorProfile {
259
334
  <span class="muted">{{ exitSkip + 1 }}–{{ exitSkip + exitPages.length }} of {{ exitTotal }}</span>
260
335
  <button class="btn btn-sm" (click)="exitNext()" [disabled]="exitSkip + exitTake >= exitTotal">Next ›</button>
261
336
  </div>
337
+ </ng-container>
262
338
  </div>
263
339
  </div>
264
340
  </div>
@@ -267,10 +343,14 @@ interface VisitorProfile {
267
343
  <vdr-page-block>
268
344
  <div class="card">
269
345
  <div class="card-block">
270
- <h3 class="card-title">
271
- Visitors
272
- <span class="muted">{{ recentTotal | number }} total · click a row for the full profile</span>
273
- </h3>
346
+ <button type="button" class="collapse-head" (click)="toggleSection('visitors')" [attr.aria-expanded]="sectionOpen['visitors']">
347
+ <h3 class="card-title">
348
+ Visitors
349
+ <span class="muted">{{ recentTotal | number }} total · click a row for the full profile</span>
350
+ </h3>
351
+ <clr-icon shape="angle" [attr.dir]="sectionOpen['visitors'] ? 'down' : 'right'"></clr-icon>
352
+ </button>
353
+ <ng-container *ngIf="sectionOpen['visitors']">
274
354
  <div *ngIf="recent.length === 0" class="muted pad">No visitors in this range.</div>
275
355
  <table class="table table-compact" *ngIf="recent.length > 0">
276
356
  <thead>
@@ -319,6 +399,7 @@ interface VisitorProfile {
319
399
  <span class="muted">{{ recentSkip + 1 }}–{{ recentSkip + recent.length }} of {{ recentTotal }}</span>
320
400
  <button class="btn btn-sm" (click)="recentNext()" [disabled]="recentSkip + recentTake >= recentTotal">Next ›</button>
321
401
  </div>
402
+ </ng-container>
322
403
  </div>
323
404
  </div>
324
405
  </vdr-page-block>
@@ -453,6 +534,72 @@ interface VisitorProfile {
453
534
  styles: [`
454
535
  :host { color: var(--color-text-100, inherit); display: block; }
455
536
 
537
+ /* ── Chart series colors — categorical identity, fixed order.
538
+ Validated (scripts/validate_palette.js): light set passes all
539
+ six checks on the light admin surface; dark steps pass on
540
+ #1f2937. Colors follow the SERIES, never its toggle rank. */
541
+ :host {
542
+ --hulo-s1: #2a78d6; /* unique visitors — blue */
543
+ --hulo-s2: #eb6834; /* sessions — orange */
544
+ --hulo-s3: #1baf7a; /* page views — aqua */
545
+ --hulo-s4: #eda100; /* events — yellow */
546
+ }
547
+ :host-context([data-theme='dark']),
548
+ :host-context(.theme-dark) {
549
+ --hulo-s1: #3987e5;
550
+ --hulo-s2: #d95926;
551
+ --hulo-s3: #199e70;
552
+ --hulo-s4: #c98500;
553
+ }
554
+
555
+ /* ── Collapsible section headers ─────────────────────────── */
556
+ .collapse-head {
557
+ display: flex; align-items: center; justify-content: space-between;
558
+ width: 100%; background: none; border: 0; padding: 0; cursor: pointer;
559
+ text-align: left; color: inherit;
560
+ }
561
+ .collapse-head .card-title { margin-bottom: 0; }
562
+ .collapse-head clr-icon { color: var(--color-component-color-300, #64748b); }
563
+ .collapse-head:hover clr-icon { color: var(--color-primary-500, #f59e0b); }
564
+ .collapse-head[aria-expanded='true'] .card-title { margin-bottom: 12px; }
565
+
566
+ /* ── Daily activity chart ────────────────────────────────── */
567
+ .series-toggles { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
568
+ .series-pill {
569
+ display: inline-flex; align-items: center; gap: 6px;
570
+ padding: 5px 12px; min-height: 30px; border-radius: 999px;
571
+ border: 1px solid var(--color-component-border-200, #d1d5db);
572
+ background: var(--color-component-bg-100, #fff);
573
+ color: var(--color-component-color-200, #475569);
574
+ font-size: 12px; font-weight: 600; cursor: pointer;
575
+ transition: border-color 0.15s ease, color 0.15s ease;
576
+ }
577
+ .series-pill.on { color: var(--color-text-100, #0f172a); border-color: var(--color-component-border-300, #94a3b8); }
578
+ .series-pill:hover { border-color: var(--color-primary-500, #f59e0b); }
579
+ .series-chip {
580
+ width: 10px; height: 10px; border-radius: 3px; display: inline-block;
581
+ border: 1.5px solid transparent; flex: 0 0 auto;
582
+ }
583
+ .chart-wrap { position: relative; }
584
+ .daily-chart { width: 100%; height: 240px; display: block; }
585
+ .gridline { stroke: var(--color-component-border-100, #e2e8f0); stroke-width: 1; }
586
+ .axis-label { fill: var(--color-component-color-300, #64748b); font-size: 10px; }
587
+ .crosshair { stroke: var(--color-component-color-300, #94a3b8); stroke-width: 1; stroke-dasharray: 3 3; }
588
+ .hover-dot { stroke: var(--color-component-bg-100, #fff); stroke-width: 2; }
589
+ .chart-tip {
590
+ position: absolute; top: 8px; transform: translateX(10px);
591
+ background: var(--color-component-bg-100, #fff);
592
+ border: 1px solid var(--color-component-border-200, #d1d5db);
593
+ border-radius: 8px; padding: 8px 10px; pointer-events: none;
594
+ box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12); z-index: 5;
595
+ min-width: 150px;
596
+ }
597
+ .chart-tip.flip { transform: translateX(calc(-100% - 10px)); }
598
+ .tip-date { font-size: 11px; font-weight: 700; color: var(--color-text-100, #0f172a); margin-bottom: 4px; }
599
+ .tip-row { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 1px 0; }
600
+ .tip-name { color: var(--color-component-color-200, #475569); flex: 1; }
601
+ .tip-val { font-weight: 700; color: var(--color-text-100, #0f172a); font-variant-numeric: tabular-nums; }
602
+
456
603
  /* ── HULO shared hero + help pattern ─────────────────────── */
457
604
  .hulo-hero {
458
605
  display: flex; align-items: center; gap: 18px;
@@ -664,6 +811,134 @@ export class VisitorsComponent implements OnInit, OnDestroy {
664
811
  channelId: number | null = null;
665
812
  channels: Array<{ id: number; code: string }> = [];
666
813
 
814
+ // ── Daily-activity chart ────────────────────────────────────────
815
+ // Series colors are FIXED per series (identity), validated for
816
+ // CVD separation + lightness on both admin surfaces — see the
817
+ // :host CSS vars. Toggling a series never repaints the others.
818
+ daily: Array<{ day: string; visitors: number; sessions: number; events: number; pageviews: number }> = [];
819
+ seriesDefs = [
820
+ { key: 'visitors', label: 'Unique visitors', color: 'var(--hulo-s1)' },
821
+ { key: 'sessions', label: 'Sessions', color: 'var(--hulo-s2)' },
822
+ { key: 'pageviews', label: 'Page views', color: 'var(--hulo-s3)' },
823
+ { key: 'events', label: 'Events', color: 'var(--hulo-s4)' },
824
+ ];
825
+ seriesOn: Record<string, boolean> = { visitors: true, sessions: true, pageviews: false, events: false };
826
+ chart: {
827
+ L: number; R: number;
828
+ ticks: Array<{ y: number; label: string }>;
829
+ xlabels: Array<{ x: number; label: string }>;
830
+ lines: Array<{ key: string; label: string; color: string; points: string; ys: number[] }>;
831
+ xs: number[];
832
+ days: Array<{ label: string; values: Record<string, number> }>;
833
+ } | null = null;
834
+ hoverIdx: number | null = null;
835
+
836
+ // ── Collapsible sections (persisted) ────────────────────────────
837
+ sectionOpen: Record<string, boolean> = { chart: true, funnel: true, top: true, exit: true, visitors: true };
838
+ private readonly prefsKey = 'hulo-visitor-journey-prefs';
839
+
840
+ private restorePrefs(): void {
841
+ try {
842
+ const p = JSON.parse(localStorage.getItem(this.prefsKey) || '{}');
843
+ if (p.series && typeof p.series === 'object') Object.assign(this.seriesOn, p.series);
844
+ if (p.sections && typeof p.sections === 'object') Object.assign(this.sectionOpen, p.sections);
845
+ // Never restore into a state with zero visible series.
846
+ if (!this.seriesDefs.some(sd => this.seriesOn[sd.key])) this.seriesOn['visitors'] = true;
847
+ } catch { /* corrupted prefs — fall back to defaults */ }
848
+ }
849
+
850
+ private savePrefs(): void {
851
+ try {
852
+ localStorage.setItem(this.prefsKey, JSON.stringify({ series: this.seriesOn, sections: this.sectionOpen }));
853
+ } catch { /* storage full/blocked — non-fatal */ }
854
+ }
855
+
856
+ toggleSection(key: string): void {
857
+ this.sectionOpen[key] = !this.sectionOpen[key];
858
+ this.savePrefs();
859
+ this.cdr.markForCheck();
860
+ }
861
+
862
+ toggleSeries(key: string): void {
863
+ // Keep at least one series visible.
864
+ const activeCount = this.seriesDefs.filter(sd => this.seriesOn[sd.key]).length;
865
+ if (this.seriesOn[key] && activeCount <= 1) return;
866
+ this.seriesOn[key] = !this.seriesOn[key];
867
+ this.savePrefs();
868
+ this.rebuildChart();
869
+ this.cdr.markForCheck();
870
+ }
871
+
872
+ /** Nice ceiling for the y-axis: 1/2/5 × 10^n at or above v. */
873
+ private niceCeil(v: number): number {
874
+ if (v <= 0) return 1;
875
+ const mag = Math.pow(10, Math.floor(Math.log10(v)));
876
+ for (const m of [1, 2, 5, 10]) {
877
+ if (m * mag >= v) return m * mag;
878
+ }
879
+ return 10 * mag;
880
+ }
881
+
882
+ private rebuildChart(): void {
883
+ const days = this.daily || [];
884
+ const active = this.seriesDefs.filter(sd => this.seriesOn[sd.key]);
885
+ if (days.length === 0 || active.length === 0) { this.chart = null; this.hoverIdx = null; return; }
886
+ const L = 44, R = 788, T = 10, B = 216;
887
+ const maxVal = Math.max(1, ...days.map(d => Math.max(...active.map(sd => Number((d as any)[sd.key]) || 0))));
888
+ const yMax = this.niceCeil(maxVal);
889
+ const x = (i: number) => (days.length === 1 ? (L + R) / 2 : L + (i * (R - L)) / (days.length - 1));
890
+ const y = (v: number) => B - (v / yMax) * (B - T);
891
+ const fmtDay = (raw: string) => {
892
+ const d = new Date(raw);
893
+ return isNaN(d.getTime()) ? String(raw).slice(5) : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
894
+ };
895
+ const xs = days.map((_, i) => Math.round(x(i) * 10) / 10);
896
+ const lines = active.map(sd => {
897
+ const ys = days.map(d => Math.round(y(Number((d as any)[sd.key]) || 0) * 10) / 10);
898
+ return {
899
+ key: sd.key, label: sd.label, color: sd.color, ys,
900
+ points: xs.map((px, i) => px + ',' + ys[i]).join(' '),
901
+ };
902
+ });
903
+ const ticks = [0, 0.5, 1].map(f => ({
904
+ y: Math.round(y(yMax * f) * 10) / 10,
905
+ label: String(Math.round(yMax * f)),
906
+ }));
907
+ const labelEvery = Math.max(1, Math.ceil(days.length / 6));
908
+ const xlabels = days
909
+ .map((d, i) => ({ i, d }))
910
+ .filter(({ i }) => i % labelEvery === 0 || i === days.length - 1)
911
+ .map(({ i, d }) => ({ x: xs[i], label: fmtDay(d.day) }));
912
+ this.chart = {
913
+ L, R, ticks, xlabels, lines, xs,
914
+ days: days.map(d => ({
915
+ label: fmtDay(d.day),
916
+ values: {
917
+ visitors: Number(d.visitors) || 0,
918
+ sessions: Number(d.sessions) || 0,
919
+ pageviews: Number(d.pageviews) || 0,
920
+ events: Number(d.events) || 0,
921
+ },
922
+ })),
923
+ };
924
+ }
925
+
926
+ onChartMove(ev: MouseEvent, svg: SVGSVGElement): void {
927
+ if (!this.chart || this.chart.xs.length === 0) return;
928
+ const rect = svg.getBoundingClientRect();
929
+ const vx = ((ev.clientX - rect.left) / rect.width) * 800;
930
+ let best = 0, bestDist = Infinity;
931
+ for (let i = 0; i < this.chart.xs.length; i++) {
932
+ const d = Math.abs(this.chart.xs[i] - vx);
933
+ if (d < bestDist) { bestDist = d; best = i; }
934
+ }
935
+ if (this.hoverIdx !== best) { this.hoverIdx = best; this.cdr.markForCheck(); }
936
+ }
937
+
938
+ onChartLeave(): void {
939
+ if (this.hoverIdx !== null) { this.hoverIdx = null; this.cdr.markForCheck(); }
940
+ }
941
+
667
942
  summary = { visitors: 0, sessions: 0, pageviews: 0, avgTimeMs: 0 };
668
943
  funnel: FunnelStage[] = [];
669
944
 
@@ -708,6 +983,7 @@ export class VisitorsComponent implements OnInit, OnDestroy {
708
983
  ) {}
709
984
 
710
985
  ngOnInit() {
986
+ this.restorePrefs();
711
987
  this.loadChannels();
712
988
  this.loadAll();
713
989
  this.connectLive();
@@ -825,6 +1101,8 @@ export class VisitorsComponent implements OnInit, OnDestroy {
825
1101
  this.fetchRecent(),
826
1102
  ]).then(([summaryRes, funnelRes]) => {
827
1103
  this.summary = summaryRes?.totals || this.summary;
1104
+ this.daily = Array.isArray(summaryRes?.daily) ? summaryRes.daily : [];
1105
+ this.rebuildChart();
828
1106
  this.funnel = funnelRes?.stages || [];
829
1107
  this.loading = false;
830
1108
  this.cdr.markForCheck();