@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@huloglobal/vendure-plugin-visitor-analytics` are
4
+ documented here. The format follows
5
+ [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this
6
+ project adheres to [semantic versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] — Unreleased
9
+
10
+ ### Added
11
+ - **UTM attribution** — `utmSource` / `utmMedium` / `utmCampaign` /
12
+ `utmTerm` / `utmContent` and `referrerDomain` columns parsed from every
13
+ incoming pageview URL. New `GET /ees/visitors/sources` admin endpoint
14
+ groups visitors by `(source, medium)` plus per-source conversion
15
+ counts (reached product page, reached cart/checkout).
16
+ - **Live-now widget** — Server-Sent Events stream at
17
+ `GET /ees/visitors/live` pushing the active-visitor count and the
18
+ 20 most recent URLs every 5 seconds. SSE clients auto-reconnect.
19
+ - **Top events** admin endpoint at `GET /ees/visitors/top-events`,
20
+ paginated.
21
+ - **Custom event recipes** — README section with copy-paste storefront
22
+ snippets for add-to-cart, search, quote-request, newsletter signup.
23
+
24
+ ## [0.1.0] — Unreleased
25
+
26
+ ### Added
27
+ - `VisitorAnalyticsPlugin` — ingest endpoint + admin dashboards.
28
+ - `VisitorEvent` entity capturing pageview / unload / event rows with
29
+ full UA parse, MaxMind geo enrichment, raw + hashed IP.
30
+ - Proxy-aware IP / country / region extraction (Cloudflare, Akamai,
31
+ Fastly headers all detected; falls back to MaxMind only if the
32
+ upstream didn't already resolve country).
33
+ - Admin endpoints: summary, top pages (paginated), exit pages
34
+ (paginated), funnel, recent visitors (paginated), per-visitor
35
+ profile + journey.
36
+ - Admin UI: summary tiles, funnel bars, top + exit page tables,
37
+ recent visitors table with clickable profile drawer.
38
+ - Licence verification via `@huloglobal/vendure-licence-sdk` with revocation
39
+ polling.
package/LICENSE ADDED
@@ -0,0 +1,34 @@
1
+ HULO Global Limited — Commercial Plugin Licence
2
+
3
+ Copyright (c) 2026 HULO Global Limited. All rights reserved.
4
+
5
+ This software is licensed, not sold, and is made available only to users
6
+ who hold a valid, active subscription or perpetual licence purchased
7
+ from HULO Global Limited.
8
+
9
+ Permitted use:
10
+
11
+ 1. The software may be installed and run on a single Vendure
12
+ instance per active subscription (or per perpetual licence). Each
13
+ licence is bound to one or more hostnames named at purchase time.
14
+
15
+ 2. The source code is published only so that customers may audit it
16
+ for security review. Modification of the source for production use
17
+ is permitted, but redistribution of the modified source or of any
18
+ derivative work — in whole or in part — is not permitted.
19
+
20
+ Prohibited use:
21
+
22
+ - Use without a valid, active licence key, except for non-production
23
+ evaluation under the plugin's "unlicensed mode" (which writes basic
24
+ delivery rows but disables the open/click endpoints).
25
+ - Use of the package to circumvent the licence verification routine,
26
+ or to extract or replace the embedded RSA public key.
27
+ - Resale, sublicensing, or distribution of the package outside of an
28
+ application that itself holds a valid HULO Vendure plugin licence.
29
+
30
+ No warranty. To the maximum extent permitted by law, HULO Global Limited
31
+ disclaims all warranties and all liability arising from use of this
32
+ software.
33
+
34
+ For commercial licensing enquiries: sales@eliteenterprisesoftware.com
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # @huloglobal/vendure-plugin-visitor-analytics
2
+
3
+ Full-funnel visitor analytics for Vendure storefronts. Captures every
4
+ pageview, time-on-page, and exit point; bundles a per-visitor profile
5
+ drawer with parsed user-agent, MaxMind geo enrichment and a complete
6
+ event timeline; stitches guest browsing to signed-in browsing across
7
+ sessions so the journey survives login.
8
+
9
+ Maintained by Wayne Garrison.
10
+
11
+ ## What you get
12
+
13
+ - **Ingest endpoint** at `POST /ees/track` that takes a small batch of
14
+ events from the storefront. Issues a long-lived visitor cookie
15
+ (`ees_vid`, 2 years) and a sliding session cookie (`ees_sid`,
16
+ 30-minute idle).
17
+ - **Auto-enrichment** at ingest time:
18
+ - User-agent parsed via `ua-parser-js` → browser / browser version /
19
+ OS / OS version / device type. Bots auto-detected.
20
+ - IP-to-geo via MaxMind GeoLite2-City (no MaxMind account required,
21
+ DB fetched at install via `geolite2-redist`). Or use the upstream
22
+ proxy's resolved country / region when Cloudflare / Akamai /
23
+ Fastly is in front — saves the lookup.
24
+ - Raw IP is kept; a SHA-256 salted hash is stored alongside for
25
+ spot-the-same-bot work.
26
+ - **Admin endpoints**: summary tiles, top pages, exit pages, funnel,
27
+ recent visitors, per-visitor profile + journey timeline. All
28
+ paginated.
29
+ - **Admin UI**: top-line tiles, funnel bars, top + exit page tables,
30
+ recent visitors with a clickable profile drawer showing every field
31
+ + per-session breakdown + the full event timeline.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ yarn add @huloglobal/vendure-plugin-visitor-analytics
37
+ ```
38
+
39
+ ## Wire up
40
+
41
+ ```ts
42
+ import { VisitorAnalyticsPlugin } from '@huloglobal/vendure-plugin-visitor-analytics';
43
+
44
+ export const config: VendureConfig = {
45
+ plugins: [
46
+ VisitorAnalyticsPlugin.init({
47
+ publicBaseUrl: 'https://shop.example.com',
48
+ licenceKey: process.env.HULO_LICENCE_KEY,
49
+ }),
50
+ ],
51
+ };
52
+ ```
53
+
54
+ Add to your admin-ui compile step:
55
+
56
+ ```ts
57
+ import { VisitorAnalyticsPlugin } from '@huloglobal/vendure-plugin-visitor-analytics';
58
+
59
+ compileUiExtensions({
60
+ outputPath: 'admin-ui',
61
+ extensions: [VisitorAnalyticsPlugin.uiExtensions /* + your other extensions */],
62
+ });
63
+ ```
64
+
65
+ ## Storefront integration
66
+
67
+ The plugin ships only the backend; the storefront emits events. A Qwik
68
+ storefront example:
69
+
70
+ ```ts
71
+ // utils/tracker.ts
72
+ const TRACK_URL = 'https://shop.example.com/ees/track';
73
+ let lastPath = '';
74
+ let pageOpenedAt = 0;
75
+
76
+ export function recordPageView(): void {
77
+ const url = location.pathname + location.search;
78
+ const events: any[] = [];
79
+ if (lastPath && lastPath !== url) {
80
+ events.push({ type: 'unload', url: lastPath, timeOnPageMs: Date.now() - pageOpenedAt });
81
+ }
82
+ events.push({ type: 'pageview', url, title: document.title, referrer: document.referrer });
83
+ lastPath = url;
84
+ pageOpenedAt = Date.now();
85
+ fetch(TRACK_URL, {
86
+ method: 'POST',
87
+ credentials: 'include',
88
+ headers: { 'content-type': 'application/json' },
89
+ body: JSON.stringify({ channelId: 1, events }),
90
+ keepalive: true,
91
+ }).catch(() => undefined);
92
+ }
93
+
94
+ // On unload, prefer sendBeacon — it survives tab-close.
95
+ window.addEventListener('pagehide', () => {
96
+ const blob = new Blob([JSON.stringify({
97
+ channelId: 1,
98
+ events: [{ type: 'unload', url: lastPath, timeOnPageMs: Date.now() - pageOpenedAt }],
99
+ })], { type: 'application/json' });
100
+ navigator.sendBeacon(TRACK_URL, blob);
101
+ });
102
+ ```
103
+
104
+ ## Custom events
105
+
106
+ Fire a `recordEvent(type, meta?)` call from your storefront whenever a
107
+ visitor does something interesting — add-to-cart, search, signup,
108
+ quote-request — and the event shows up in the admin "Top events" table
109
+ straight away.
110
+
111
+ ```ts
112
+ function recordEvent(type, meta) {
113
+ navigator.sendBeacon(TRACK_URL, new Blob([JSON.stringify({
114
+ channelId: 1,
115
+ events: [{ type, url: location.pathname + location.search, meta }],
116
+ })], { type: 'application/json' }));
117
+ }
118
+
119
+ // Add to cart
120
+ recordEvent('add_to_cart', { sku: 'WIN11-PRO', priceWithTax: 28900 });
121
+
122
+ // Search query submitted
123
+ recordEvent('search', { query: 'windows server 2022' });
124
+
125
+ // Quote request from the contact form
126
+ recordEvent('quote_request', { customerEmail });
127
+
128
+ // Newsletter signup
129
+ recordEvent('newsletter_signup', { source: 'footer' });
130
+ ```
131
+
132
+ The full meta blob is persisted as JSON on the event row so you can
133
+ slice on it later from the admin UI.
134
+
135
+ ## UTM attribution
136
+
137
+ The plugin parses `utm_source` / `utm_medium` / `utm_campaign` /
138
+ `utm_term` / `utm_content` from the URL of every incoming event and
139
+ captures the referrer domain alongside. The admin "Traffic sources"
140
+ table groups visitors by `(source, medium)` so you can see which
141
+ campaigns convert.
142
+
143
+ Drop `?utm_source=google&utm_medium=cpc&utm_campaign=spring24` onto any
144
+ inbound link and it surfaces automatically — no extra config.
145
+
146
+ ## Live now widget
147
+
148
+ The admin dashboard's top tile streams the currently-active visitor
149
+ count + the URLs they're on in real time via Server-Sent Events
150
+ (`GET /ees/visitors/live`). Updates every 5 seconds, reconnects
151
+ automatically if the connection drops. Active = at least one event in
152
+ the last 5 minutes.
153
+
154
+ ## Init options
155
+
156
+ | Option | Type | Required | Description |
157
+ | --- | --- | --- | --- |
158
+ | `publicBaseUrl` | `string` | yes | Public hostname of your Vendure server (must match licence). |
159
+ | `licenceKey` | `string` | no* | JWT licence key. Without it ingest works but the admin dashboards return 403. |
160
+
161
+ \* Required for production use. Buy at
162
+ `https://elite-software.co.uk/licence/buy/vendure-plugin-visitor-analytics`.
163
+
164
+ ## Licence
165
+
166
+ Commercial — see [LICENSE](./LICENSE). Requires an active subscription
167
+ ($9.95/mo) or a perpetual licence.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@huloglobal/vendure-plugin-visitor-analytics` — public exports.
3
+ */
4
+ export { VisitorAnalyticsPlugin, VisitorAnalyticsPluginOptions } from './plugin';
5
+ export { VisitorTrackingService } from './visitor-tracking.service';
6
+ export { VisitorEvent } from './visitor-event.entity';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,sBAAsB,EAAE,6BAA6B,EAAE,MAAM,UAAU,CAAC;AACjF,OAAO,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AACpE,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ /**
3
+ * `@huloglobal/vendure-plugin-visitor-analytics` — public exports.
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.VisitorEvent = exports.VisitorTrackingService = exports.VisitorAnalyticsPlugin = void 0;
7
+ var plugin_1 = require("./plugin");
8
+ Object.defineProperty(exports, "VisitorAnalyticsPlugin", { enumerable: true, get: function () { return plugin_1.VisitorAnalyticsPlugin; } });
9
+ var visitor_tracking_service_1 = require("./visitor-tracking.service");
10
+ Object.defineProperty(exports, "VisitorTrackingService", { enumerable: true, get: function () { return visitor_tracking_service_1.VisitorTrackingService; } });
11
+ var visitor_event_entity_1 = require("./visitor-event.entity");
12
+ Object.defineProperty(exports, "VisitorEvent", { enumerable: true, get: function () { return visitor_event_entity_1.VisitorEvent; } });
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;GAEG;;;AAEH,mCAAiF;AAAxE,gHAAA,sBAAsB,OAAA;AAC/B,uEAAoE;AAA3D,kIAAA,sBAAsB,OAAA;AAC/B,+DAAsD;AAA7C,oHAAA,YAAY,OAAA"}
@@ -0,0 +1,47 @@
1
+ import { Type } from '@vendure/core';
2
+ export interface VisitorAnalyticsPluginOptions {
3
+ /** Public host of the Vendure server. Used in licence host-match. */
4
+ publicBaseUrl: string;
5
+ /** JWT licence key. Without it the ingest endpoint accepts events
6
+ * and writes basic rows, but the admin analytics endpoints return
7
+ * 403 — i.e. data collection works for evaluation but you can't
8
+ * read the dashboard without a licence. */
9
+ licenceKey?: string;
10
+ }
11
+ /**
12
+ * `@huloglobal/vendure-plugin-visitor-analytics`
13
+ *
14
+ * Full-funnel visitor analytics: page views, time-on-page, exit pages,
15
+ * a configurable funnel, and a per-visitor profile drawer with
16
+ * parsed user-agent + MaxMind GeoLite2 geo enrichment. Survives login
17
+ * — guest events and signed-in events share the same `visitorId`.
18
+ *
19
+ * Add to your Vendure config:
20
+ *
21
+ * ```ts
22
+ * import { VisitorAnalyticsPlugin } from '@huloglobal/vendure-plugin-visitor-analytics';
23
+ *
24
+ * export const config: VendureConfig = {
25
+ * plugins: [
26
+ * VisitorAnalyticsPlugin.init({
27
+ * publicBaseUrl: 'https://shop.example.com',
28
+ * licenceKey: process.env.HULO_LICENCE_KEY,
29
+ * }),
30
+ * ],
31
+ * };
32
+ * ```
33
+ */
34
+ export declare class VisitorAnalyticsPlugin {
35
+ private static revocation;
36
+ static init(options: VisitorAnalyticsPluginOptions): Type<VisitorAnalyticsPlugin>;
37
+ static uiExtensions: {
38
+ extensionPath: string;
39
+ ngModules: {
40
+ type: "lazy";
41
+ route: string;
42
+ ngModuleFileName: string;
43
+ ngModuleName: string;
44
+ }[];
45
+ };
46
+ }
47
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,IAAI,EAAiB,MAAM,eAAe,CAAC;AAMxE,MAAM,WAAW,6BAA6B;IAC1C,qEAAqE;IACrE,aAAa,EAAE,MAAM,CAAC;IACtB;;;gDAG4C;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAgBD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAOa,sBAAsB;IAC/B,OAAO,CAAC,MAAM,CAAC,UAAU,CAAkC;IAE3D,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,6BAA6B,GAAG,IAAI,CAAC,sBAAsB,CAAC;IA2BjF,MAAM,CAAC,YAAY;;;;;;;;MAUjB;CACL"}
package/dist/plugin.js ADDED
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var VisitorAnalyticsPlugin_1;
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.VisitorAnalyticsPlugin = void 0;
11
+ const core_1 = require("@vendure/core");
12
+ const vendure_licence_sdk_1 = require("@huloglobal/vendure-licence-sdk");
13
+ const visitor_event_entity_1 = require("./visitor-event.entity");
14
+ const visitor_tracking_service_1 = require("./visitor-tracking.service");
15
+ const visitor_tracking_controller_1 = require("./visitor-tracking.controller");
16
+ const HULO_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
17
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoLmNM5UljRqe71drM6lR
18
+ Ba5vXrLOcV3GAHkYvnVFQSqdE0avrge/jsD7WdA6x8qQFNRugxQcxDJa2l0+C+BH
19
+ SbU9TimGwhA1yusHHfuz9LAXks5IQ48+2e6Pulh7iThXPJUnIKqKZUN5HhL79aaK
20
+ vrZKIgSfVhwE5PMPXWZ+Ij5IRf74PLIUn1Er75qhBXlDJ4vF8y8/3owURNC1XiUB
21
+ DGElwV/LYNoqAQei4oixe4EAxPGvFi11pgHiGuRxuWckA88y6ZHLt6urfAY9sCkj
22
+ kF+2dc2yS3j7lD+SYAaV5LQYYjePP1CYvxCZ7HHRKqthHopxY1hsK2tBtni3f7/c
23
+ UwIDAQAB
24
+ -----END PUBLIC KEY-----`;
25
+ const PLUGIN_ID = 'vendure-plugin-visitor-analytics';
26
+ const REVOCATION_URL = process.env.HULO_LICENCE_REVOCATION_URL
27
+ || 'https://elite.charity/licence/revoked.json';
28
+ /**
29
+ * `@huloglobal/vendure-plugin-visitor-analytics`
30
+ *
31
+ * Full-funnel visitor analytics: page views, time-on-page, exit pages,
32
+ * a configurable funnel, and a per-visitor profile drawer with
33
+ * parsed user-agent + MaxMind GeoLite2 geo enrichment. Survives login
34
+ * — guest events and signed-in events share the same `visitorId`.
35
+ *
36
+ * Add to your Vendure config:
37
+ *
38
+ * ```ts
39
+ * import { VisitorAnalyticsPlugin } from '@huloglobal/vendure-plugin-visitor-analytics';
40
+ *
41
+ * export const config: VendureConfig = {
42
+ * plugins: [
43
+ * VisitorAnalyticsPlugin.init({
44
+ * publicBaseUrl: 'https://shop.example.com',
45
+ * licenceKey: process.env.HULO_LICENCE_KEY,
46
+ * }),
47
+ * ],
48
+ * };
49
+ * ```
50
+ */
51
+ let VisitorAnalyticsPlugin = VisitorAnalyticsPlugin_1 = class VisitorAnalyticsPlugin {
52
+ static init(options) {
53
+ if (!VisitorAnalyticsPlugin_1.revocation) {
54
+ VisitorAnalyticsPlugin_1.revocation = new vendure_licence_sdk_1.RevocationChecker(REVOCATION_URL);
55
+ VisitorAnalyticsPlugin_1.revocation.start();
56
+ }
57
+ const host = (options.publicBaseUrl || '')
58
+ .replace(/^https?:\/\//, '').replace(/\/.*$/, '');
59
+ const status = (0, vendure_licence_sdk_1.verifyLicence)({
60
+ licenceKey: options.licenceKey,
61
+ pluginId: PLUGIN_ID,
62
+ host,
63
+ publicKey: HULO_PUBLIC_KEY,
64
+ revokedIds: VisitorAnalyticsPlugin_1.revocation.getRevokedIds(),
65
+ });
66
+ if (!status.valid) {
67
+ // eslint-disable-next-line no-console
68
+ console.warn(`[@huloglobal/vendure-plugin-visitor-analytics] ${status.message}` +
69
+ ` — Running in unlicensed mode (ingest works, admin dashboards disabled). Purchase a key at https://elite-software.co.uk/licence/buy/${PLUGIN_ID}`);
70
+ }
71
+ return VisitorAnalyticsPlugin_1;
72
+ }
73
+ };
74
+ exports.VisitorAnalyticsPlugin = VisitorAnalyticsPlugin;
75
+ VisitorAnalyticsPlugin.revocation = null;
76
+ VisitorAnalyticsPlugin.uiExtensions = {
77
+ extensionPath: __dirname + '/../ui',
78
+ ngModules: [
79
+ {
80
+ type: 'lazy',
81
+ route: 'visitors',
82
+ ngModuleFileName: 'visitors.module.ts',
83
+ ngModuleName: 'VisitorsModule',
84
+ },
85
+ ],
86
+ };
87
+ exports.VisitorAnalyticsPlugin = VisitorAnalyticsPlugin = VisitorAnalyticsPlugin_1 = __decorate([
88
+ (0, core_1.VendurePlugin)({
89
+ imports: [core_1.PluginCommonModule],
90
+ providers: [visitor_tracking_service_1.VisitorTrackingService],
91
+ controllers: [visitor_tracking_controller_1.VisitorTrackingController],
92
+ entities: [visitor_event_entity_1.VisitorEvent],
93
+ compatibility: '^3.0.0',
94
+ })
95
+ ], VisitorAnalyticsPlugin);
96
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":";;;;;;;;;;AAAA,wCAAwE;AACxE,yEAAmF;AACnF,iEAAsD;AACtD,yEAAoE;AACpE,+EAA0E;AAY1E,MAAM,eAAe,GAAG;;;;;;;;yBAQC,CAAC;AAE1B,MAAM,SAAS,GAAG,kCAAkC,CAAC;AACrD,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,2BAA2B;OACvD,4CAA4C,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAQI,IAAM,sBAAsB,8BAA5B,MAAM,sBAAsB;IAG/B,MAAM,CAAC,IAAI,CAAC,OAAsC;QAC9C,IAAI,CAAC,wBAAsB,CAAC,UAAU,EAAE,CAAC;YACrC,wBAAsB,CAAC,UAAU,GAAG,IAAI,uCAAiB,CAAC,cAAc,CAAC,CAAC;YAC1E,wBAAsB,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;QAC9C,CAAC;QAED,MAAM,IAAI,GAAG,CAAC,OAAO,CAAC,aAAa,IAAI,EAAE,CAAC;aACrC,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACtD,MAAM,MAAM,GAAG,IAAA,mCAAa,EAAC;YACzB,UAAU,EAAE,OAAO,CAAC,UAAU;YAC9B,QAAQ,EAAE,SAAS;YACnB,IAAI;YACJ,SAAS,EAAE,eAAe;YAC1B,UAAU,EAAE,wBAAsB,CAAC,UAAU,CAAC,aAAa,EAAE;SAChE,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;YAChB,sCAAsC;YACtC,OAAO,CAAC,IAAI,CACR,kDAAkD,MAAM,CAAC,OAAO,EAAE;gBAClE,uIAAuI,SAAS,EAAE,CACrJ,CAAC;QACN,CAAC;QAED,OAAO,wBAAsB,CAAC;IAClC,CAAC;;AA5BQ,wDAAsB;AAChB,iCAAU,GAA6B,IAAI,AAAjC,CAAkC;AA6BpD,mCAAY,GAAG;IAClB,aAAa,EAAE,SAAS,GAAG,QAAQ;IACnC,SAAS,EAAE;QACP;YACI,IAAI,EAAE,MAAe;YACrB,KAAK,EAAE,UAAU;YACjB,gBAAgB,EAAE,oBAAoB;YACtC,YAAY,EAAE,gBAAgB;SACjC;KACJ;CACJ,AAVkB,CAUjB;iCAxCO,sBAAsB;IAPlC,IAAA,oBAAa,EAAC;QACX,OAAO,EAAE,CAAC,yBAAkB,CAAC;QAC7B,SAAS,EAAE,CAAC,iDAAsB,CAAC;QACnC,WAAW,EAAE,CAAC,uDAAyB,CAAC;QACxC,QAAQ,EAAE,CAAC,mCAAY,CAAC;QACxB,aAAa,EAAE,QAAQ;KAC1B,CAAC;GACW,sBAAsB,CAyClC"}
@@ -0,0 +1,37 @@
1
+ import { Request } from 'express';
2
+ /**
3
+ * Reverse-proxy aware visitor IP extraction.
4
+ *
5
+ * Order of precedence:
6
+ * 1. Cloudflare's `CF-Connecting-IP` (always the real client IP,
7
+ * regardless of how many proxies sit in front of the worker).
8
+ * 2. `True-Client-IP` (Akamai / Cloudflare Enterprise).
9
+ * 3. `X-Real-IP` (nginx / Caddy default when proxying).
10
+ * 4. First entry in `X-Forwarded-For` (RFC 7239 ancestor; the
11
+ * left-most entry is the original client when the upstream proxy
12
+ * is trusted).
13
+ * 5. Express's `req.ip` — only useful when `app.set('trust proxy', ...)`
14
+ * has been set on the Vendure host, otherwise this is the socket
15
+ * address of the last hop.
16
+ *
17
+ * Returns `null` if none of the headers are populated and `req.ip`
18
+ * isn't available — the caller should treat this as "unknown" and
19
+ * skip IP-dependent enrichment rather than fail.
20
+ */
21
+ export declare function getRealIp(req: Request): string | null;
22
+ /**
23
+ * Cloudflare / Akamai populate the visitor's resolved country on the
24
+ * inbound request when the corresponding feature is enabled. Reading
25
+ * the upstream value avoids a per-request GeoIP lookup. Returns the
26
+ * ISO 3166-1 alpha-2 country code or `null` if no proxy header is
27
+ * present.
28
+ */
29
+ export declare function getResolvedCountry(req: Request): string | null;
30
+ /**
31
+ * Cloudflare's `cf-region-code` carries the ISO 3166-2 subdivision
32
+ * (e.g. `ENG`, `SCT`, `CA`) when the "Send subdivision data" option is
33
+ * enabled in the dashboard. Returns the bare code without the country
34
+ * prefix, or `null` if unavailable.
35
+ */
36
+ export declare function getResolvedRegion(req: Request): string | null;
37
+ //# sourceMappingURL=proxy-headers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy-headers.d.ts","sourceRoot":"","sources":["../src/proxy-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAElC;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAkBrD;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAe9D;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAK7D"}
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getRealIp = getRealIp;
4
+ exports.getResolvedCountry = getResolvedCountry;
5
+ exports.getResolvedRegion = getResolvedRegion;
6
+ /**
7
+ * Reverse-proxy aware visitor IP extraction.
8
+ *
9
+ * Order of precedence:
10
+ * 1. Cloudflare's `CF-Connecting-IP` (always the real client IP,
11
+ * regardless of how many proxies sit in front of the worker).
12
+ * 2. `True-Client-IP` (Akamai / Cloudflare Enterprise).
13
+ * 3. `X-Real-IP` (nginx / Caddy default when proxying).
14
+ * 4. First entry in `X-Forwarded-For` (RFC 7239 ancestor; the
15
+ * left-most entry is the original client when the upstream proxy
16
+ * is trusted).
17
+ * 5. Express's `req.ip` — only useful when `app.set('trust proxy', ...)`
18
+ * has been set on the Vendure host, otherwise this is the socket
19
+ * address of the last hop.
20
+ *
21
+ * Returns `null` if none of the headers are populated and `req.ip`
22
+ * isn't available — the caller should treat this as "unknown" and
23
+ * skip IP-dependent enrichment rather than fail.
24
+ */
25
+ function getRealIp(req) {
26
+ var _a;
27
+ const headers = req.headers || {};
28
+ const cfIp = String(headers['cf-connecting-ip'] || '').trim();
29
+ if (cfIp)
30
+ return cfIp;
31
+ const trueClient = String(headers['true-client-ip'] || '').trim();
32
+ if (trueClient)
33
+ return trueClient;
34
+ const realIp = String(headers['x-real-ip'] || '').trim();
35
+ if (realIp)
36
+ return realIp;
37
+ const xff = String(headers['x-forwarded-for'] || '').trim();
38
+ if (xff) {
39
+ const first = (_a = xff.split(',')[0]) === null || _a === void 0 ? void 0 : _a.trim();
40
+ if (first)
41
+ return first;
42
+ }
43
+ return req.ip || null;
44
+ }
45
+ /**
46
+ * Cloudflare / Akamai populate the visitor's resolved country on the
47
+ * inbound request when the corresponding feature is enabled. Reading
48
+ * the upstream value avoids a per-request GeoIP lookup. Returns the
49
+ * ISO 3166-1 alpha-2 country code or `null` if no proxy header is
50
+ * present.
51
+ */
52
+ function getResolvedCountry(req) {
53
+ const headers = req.headers || {};
54
+ const cf = String(headers['cf-ipcountry'] || '').trim().toUpperCase();
55
+ if (cf && cf !== 'XX' && cf !== 'T1')
56
+ return cf;
57
+ const akamai = String(headers['x-akamai-edgescape'] || '').trim();
58
+ if (akamai) {
59
+ const m = akamai.match(/country_code=([A-Z]{2})/i);
60
+ if (m)
61
+ return m[1].toUpperCase();
62
+ }
63
+ const fastly = String(headers['x-country-code'] || '').trim().toUpperCase();
64
+ if (fastly && /^[A-Z]{2}$/.test(fastly))
65
+ return fastly;
66
+ return null;
67
+ }
68
+ /**
69
+ * Cloudflare's `cf-region-code` carries the ISO 3166-2 subdivision
70
+ * (e.g. `ENG`, `SCT`, `CA`) when the "Send subdivision data" option is
71
+ * enabled in the dashboard. Returns the bare code without the country
72
+ * prefix, or `null` if unavailable.
73
+ */
74
+ function getResolvedRegion(req) {
75
+ const headers = req.headers || {};
76
+ const cf = String(headers['cf-region-code'] || '').trim().toUpperCase();
77
+ if (cf && /^[A-Z0-9]{1,4}$/.test(cf))
78
+ return cf;
79
+ return null;
80
+ }
81
+ //# sourceMappingURL=proxy-headers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy-headers.js","sourceRoot":"","sources":["../src/proxy-headers.ts"],"names":[],"mappings":";;AAqBA,8BAkBC;AASD,gDAeC;AAQD,8CAKC;AA1ED;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,SAAS,CAAC,GAAY;;IAClC,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;IAClC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,kBAAkB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9D,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC;IAEtB,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAClE,IAAI,UAAU;QAAE,OAAO,UAAU,CAAC;IAElC,MAAM,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACzD,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAE1B,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC5D,IAAI,GAAG,EAAE,CAAC;QACN,MAAM,KAAK,GAAG,MAAA,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,0CAAE,IAAI,EAAE,CAAC;QACxC,IAAI,KAAK;YAAE,OAAO,KAAK,CAAC;IAC5B,CAAC;IAED,OAAQ,GAAW,CAAC,EAAE,IAAI,IAAI,CAAC;AACnC,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,kBAAkB,CAAC,GAAY;IAC3C,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;IAClC,MAAM,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACtE,IAAI,EAAE,IAAI,EAAE,KAAK,IAAI,IAAI,EAAE,KAAK,IAAI;QAAE,OAAO,EAAE,CAAC;IAEhD,MAAM,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,oBAAoB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAClE,IAAI,MAAM,EAAE,CAAC;QACT,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,0BAA0B,CAAC,CAAC;QACnD,IAAI,CAAC;YAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IACrC,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC5E,IAAI,MAAM,IAAI,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC;QAAE,OAAO,MAAM,CAAC;IAEvD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,SAAgB,iBAAiB,CAAC,GAAY;IAC1C,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;IAClC,MAAM,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACxE,IAAI,EAAE,IAAI,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC;QAAE,OAAO,EAAE,CAAC;IAChD,OAAO,IAAI,CAAC;AAChB,CAAC"}
@@ -0,0 +1,68 @@
1
+ import { DeepPartial, VendureEntity } from '@vendure/core';
2
+ /**
3
+ * Stores a single visitor analytics event — used to map the customer
4
+ * journey across the storefront, including for visitors who never log in.
5
+ *
6
+ * type='pageview' — a new page was opened (one row per navigation)
7
+ * type='unload' — a page was closed / hidden; `timeOnPageMs` is the
8
+ * time the visitor spent on it before leaving
9
+ * type='event' — a custom event raised by the frontend (e.g. an
10
+ * add-to-cart click); the payload lives in `meta`
11
+ *
12
+ * `visitorId` is a long-lived cookie UUID (2 years) — identifies the
13
+ * device across sessions. `sessionId` is a short-lived cookie UUID
14
+ * (30-minute idle expiry) — identifies a single browsing burst.
15
+ *
16
+ * `customerId` is set when the visitor is logged in; the same
17
+ * `visitorId` can carry guest events first and then customer events
18
+ * once the visitor signs in / signs up — that's what makes the funnel
19
+ * analysis work.
20
+ */
21
+ export declare class VisitorEvent extends VendureEntity {
22
+ constructor(input?: DeepPartial<VisitorEvent>);
23
+ visitorId: string;
24
+ sessionId: string;
25
+ customerId: number | null;
26
+ channelId: number;
27
+ type: 'pageview' | 'unload' | 'event' | string;
28
+ /** Pathname + search, no host. Capped at 2048 to fit Cloudflare's max URL. */
29
+ url: string;
30
+ /** Document title at the time of the event — useful for the journey
31
+ * timeline so the admin sees "Product: Windows 11" instead of just
32
+ * "/products/microsoft-windows-11-professional/". */
33
+ title: string | null;
34
+ referrer: string | null;
35
+ /** Set only on `unload` rows — milliseconds the visitor spent on the
36
+ * page before leaving. */
37
+ timeOnPageMs: number | null;
38
+ /** Hash of the visitor's IP address — kept for spot-the-same-IP-bot
39
+ * analysis even after the raw IP is purged. */
40
+ ipHash: string | null;
41
+ /** Raw client IP. Max length 45 chars covers full IPv6 + zone id. */
42
+ ip: string | null;
43
+ userAgent: string | null;
44
+ /** Parsed user-agent — populated server-side at ingest. */
45
+ browser: string | null;
46
+ browserVersion: string | null;
47
+ os: string | null;
48
+ osVersion: string | null;
49
+ device: string | null;
50
+ /** `Accept-Language` header, capped. */
51
+ acceptLanguage: string | null;
52
+ country: string | null;
53
+ region: string | null;
54
+ city: string | null;
55
+ timezone: string | null;
56
+ /** Arbitrary JSON payload for `type='event'` rows. */
57
+ meta: string | null;
58
+ utmSource: string | null;
59
+ utmMedium: string | null;
60
+ utmCampaign: string | null;
61
+ utmTerm: string | null;
62
+ utmContent: string | null;
63
+ /** Lowercased host of `referrer` (e.g. `google.com`, `facebook.com`).
64
+ * Stored alongside the full referrer string so admin reports can
65
+ * group by domain without parsing every URL at read time. */
66
+ referrerDomain: string | null;
67
+ }
68
+ //# sourceMappingURL=visitor-event.entity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visitor-event.entity.d.ts","sourceRoot":"","sources":["../src/visitor-event.entity.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAG3D;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAMa,YAAa,SAAQ,aAAa;gBAC/B,KAAK,CAAC,EAAE,WAAW,CAAC,YAAY,CAAC;IAG7C,SAAS,EAAG,MAAM,CAAC;IAGnB,SAAS,EAAG,MAAM,CAAC;IAGnB,UAAU,EAAG,MAAM,GAAG,IAAI,CAAC;IAG3B,SAAS,EAAG,MAAM,CAAC;IAGnB,IAAI,EAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;IAEhD,8EAA8E;IAE9E,GAAG,EAAG,MAAM,CAAC;IAEb;;0DAEsD;IAEtD,KAAK,EAAG,MAAM,GAAG,IAAI,CAAC;IAGtB,QAAQ,EAAG,MAAM,GAAG,IAAI,CAAC;IAEzB;+BAC2B;IAE3B,YAAY,EAAG,MAAM,GAAG,IAAI,CAAC;IAE7B;oDACgD;IAEhD,MAAM,EAAG,MAAM,GAAG,IAAI,CAAC;IAEvB,qEAAqE;IAErE,EAAE,EAAG,MAAM,GAAG,IAAI,CAAC;IAGnB,SAAS,EAAG,MAAM,GAAG,IAAI,CAAC;IAE1B,2DAA2D;IAE3D,OAAO,EAAG,MAAM,GAAG,IAAI,CAAC;IAGxB,cAAc,EAAG,MAAM,GAAG,IAAI,CAAC;IAG/B,EAAE,EAAG,MAAM,GAAG,IAAI,CAAC;IAGnB,SAAS,EAAG,MAAM,GAAG,IAAI,CAAC;IAG1B,MAAM,EAAG,MAAM,GAAG,IAAI,CAAC;IAEvB,wCAAwC;IAExC,cAAc,EAAG,MAAM,GAAG,IAAI,CAAC;IAG/B,OAAO,EAAG,MAAM,GAAG,IAAI,CAAC;IAGxB,MAAM,EAAG,MAAM,GAAG,IAAI,CAAC;IAGvB,IAAI,EAAG,MAAM,GAAG,IAAI,CAAC;IAGrB,QAAQ,EAAG,MAAM,GAAG,IAAI,CAAC;IAEzB,sDAAsD;IAEtD,IAAI,EAAG,MAAM,GAAG,IAAI,CAAC;IAUrB,SAAS,EAAG,MAAM,GAAG,IAAI,CAAC;IAG1B,SAAS,EAAG,MAAM,GAAG,IAAI,CAAC;IAG1B,WAAW,EAAG,MAAM,GAAG,IAAI,CAAC;IAG5B,OAAO,EAAG,MAAM,GAAG,IAAI,CAAC;IAGxB,UAAU,EAAG,MAAM,GAAG,IAAI,CAAC;IAE3B;;kEAE8D;IAE9D,cAAc,EAAG,MAAM,GAAG,IAAI,CAAC;CAClC"}