@huloglobal/vendure-plugin-visitor-analytics 0.8.0 → 0.8.2
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 +82 -0
- package/README.md +231 -57
- package/dist/hulo-storefront.d.ts +17 -0
- package/dist/hulo-storefront.d.ts.map +1 -0
- package/dist/hulo-storefront.js +289 -0
- package/dist/hulo-storefront.js.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +2 -0
- package/dist/plugin.js.map +1 -1
- package/dist/storefront-helper.controller.d.ts +16 -0
- package/dist/storefront-helper.controller.d.ts.map +1 -0
- package/dist/storefront-helper.controller.js +56 -0
- package/dist/storefront-helper.controller.js.map +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,87 @@ documented here. The format follows
|
|
|
5
5
|
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project
|
|
6
6
|
adheres to [semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.0] — 2026-07-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
**Cart abandonment — end-to-end.**
|
|
13
|
+
- New `AbandonedCart` entity, keyed on session id. One row per
|
|
14
|
+
abandoned session, refreshed in place until it either converts
|
|
15
|
+
(order placed) or expires (recovery window elapses).
|
|
16
|
+
- `AbandonedCartService.scan()` — periodic sweep finds sessions with
|
|
17
|
+
`cart_snapshot` events but no `checkout_completed` in the
|
|
18
|
+
abandonment window (default 30 min). Auto-promotes previously
|
|
19
|
+
abandoned rows to `converted` when the customer later checks out.
|
|
20
|
+
- Ships with a boot-time timer (worker-only, 5-minute cadence).
|
|
21
|
+
Idempotent — safe to horizontally scale, only the worker runs it.
|
|
22
|
+
- Signed recovery links — `POST /ees/abandoned-carts/:id/recovery-link`
|
|
23
|
+
returns a time-bounded opaque token the storefront exchanges via
|
|
24
|
+
`GET /ees/recover-cart?t=…` to rebuild the exact cart. Storefront
|
|
25
|
+
never sees the underlying items until the token is presented.
|
|
26
|
+
- Slack notification for high-value abandonments — configurable
|
|
27
|
+
threshold and webhook via the `abandonment` plugin option.
|
|
28
|
+
- Admin API:
|
|
29
|
+
- `GET /ees/abandoned-carts` — paginated list with status,
|
|
30
|
+
value and email filters.
|
|
31
|
+
- `GET /ees/abandoned-carts/summary` — totals, recovery rate,
|
|
32
|
+
recovered vs. lost value in the window.
|
|
33
|
+
- `GET /ees/abandoned-carts/:id` — detail incl. parsed items.
|
|
34
|
+
- `POST /ees/abandoned-carts/:id/status` — mark recovered /
|
|
35
|
+
dismissed / re-open.
|
|
36
|
+
- `GET /ees/abandoned-carts/export.csv` — CSV export.
|
|
37
|
+
|
|
38
|
+
**Co-view product recommendations.**
|
|
39
|
+
- New `ProductCoView` aggregate table. Scanner walks recent
|
|
40
|
+
`product_view` events per session, extracts every ordered pair,
|
|
41
|
+
and increments a per-triple counter. Bounded to 20 events per
|
|
42
|
+
session so runaway bot sessions can't skew the table.
|
|
43
|
+
- Denormalised — both `(A, B)` and `(B, A)` stored — so read-side
|
|
44
|
+
lookups are one indexed scan.
|
|
45
|
+
- Runs every 6 hours on the worker; also exposed as
|
|
46
|
+
`GET /ees/recommendations/aggregate-now` for admins to kick a
|
|
47
|
+
fresh run after a data backfill.
|
|
48
|
+
- Public read endpoints (safe from the storefront):
|
|
49
|
+
- `GET /ees/recommendations/also-viewed?productId=…` — the
|
|
50
|
+
"customers who viewed X also viewed…" rail on a product page.
|
|
51
|
+
- `GET /ees/recommendations/personal?visitorId=…` — personalised
|
|
52
|
+
recs for a returning visitor, from their last 10 product views
|
|
53
|
+
over 30 days. Excludes seeds.
|
|
54
|
+
- `GET /ees/recommendations/trending?hours=24` — most-viewed
|
|
55
|
+
products in the window. Reflects real interest, not search-
|
|
56
|
+
console clicks.
|
|
57
|
+
|
|
58
|
+
**Site search analytics.**
|
|
59
|
+
- Reads back over `visitor_event` where the storefront has fired
|
|
60
|
+
`hulo.search(query, resultsCount)`. Zero new schema.
|
|
61
|
+
- `GET /ees/search-analytics/top` — top queries by volume with
|
|
62
|
+
average results count.
|
|
63
|
+
- `GET /ees/search-analytics/no-results` — top zero-result queries.
|
|
64
|
+
Direct catalogue-gap intel.
|
|
65
|
+
- `GET /ees/search-analytics/conversion` — of sessions that
|
|
66
|
+
searched, what fraction went on to fire `add_to_cart`.
|
|
67
|
+
|
|
68
|
+
**Journey drawer buffs.**
|
|
69
|
+
- Rage-click + dead-click aggregation, keyed on URL. Store-wide
|
|
70
|
+
hot-spot lists for pages where visitors are stuck or frustrated.
|
|
71
|
+
- Per-visitor session summary with a heuristic `intent` label
|
|
72
|
+
(`purchase` / `abandon` / `frustrate` / `consider` / `browse` /
|
|
73
|
+
`bounce`) computed from event history — one glance per session
|
|
74
|
+
in the Journey drawer instead of scrolling event rows.
|
|
75
|
+
|
|
76
|
+
**Storefront helper events (documented in the README).**
|
|
77
|
+
- `hulo.cartSnapshot({ currency, totalMinor, itemCount, items, email })`
|
|
78
|
+
- `hulo.productView(productId, productVariantId?)`
|
|
79
|
+
- `hulo.search(query, resultsCount)`
|
|
80
|
+
- `hulo.rageClick(url, selector?)` / `hulo.deadClick(url, selector)`
|
|
81
|
+
- `hulo.checkoutCompleted()`
|
|
82
|
+
- All flow into the existing `POST /ees/track` endpoint with a
|
|
83
|
+
standard shape, so admins can also fire them from any language.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
- `checkout_completed` is now a first-class recognised event type
|
|
87
|
+
— the abandonment scanner uses it to auto-close matched rows.
|
|
88
|
+
|
|
8
89
|
## [0.7.0] — 2026-07-04
|
|
9
90
|
|
|
10
91
|
### Added
|
|
@@ -149,6 +230,7 @@ adheres to [semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
149
230
|
- Licence verification via `@huloglobal/vendure-licence-sdk` with
|
|
150
231
|
revocation polling.
|
|
151
232
|
|
|
233
|
+
[0.8.0]: https://github.com/exceeded/vendure-plugin-visitor-analytics/releases/tag/v0.8.0
|
|
152
234
|
[0.7.0]: https://github.com/exceeded/vendure-plugin-visitor-analytics/releases/tag/v0.7.0
|
|
153
235
|
[0.6.0]: https://github.com/exceeded/vendure-plugin-visitor-analytics/releases/tag/v0.6.0
|
|
154
236
|
[0.5.0]: https://github.com/exceeded/vendure-plugin-visitor-analytics/releases/tag/v0.5.0
|
package/README.md
CHANGED
|
@@ -7,6 +7,14 @@ and a per-visitor profile drawer with parsed user-agent and MaxMind
|
|
|
7
7
|
geo. Privacy-first defaults: DNT, IP anonymisation, optional consent
|
|
8
8
|
gate.
|
|
9
9
|
|
|
10
|
+
Since 0.8.0 the plugin also ships **cart abandonment** (detection,
|
|
11
|
+
signed recovery links, Slack notification, admin dashboard),
|
|
12
|
+
**co-view product recommendations** (`also-viewed` / `personal` /
|
|
13
|
+
`trending`), **site search analytics** (top queries, zero-result
|
|
14
|
+
queries, search-to-cart conversion) and **journey-drawer buffs**
|
|
15
|
+
(rage-click + dead-click hot-spot lists, per-session `intent`
|
|
16
|
+
labels).
|
|
17
|
+
|
|
10
18
|
Maintained by Wayne Garrison.
|
|
11
19
|
|
|
12
20
|
## Buy
|
|
@@ -45,55 +53,111 @@ export const config: VendureConfig = {
|
|
|
45
53
|
|
|
46
54
|
// -- Retention (opt-in) --
|
|
47
55
|
retention: { days: 365, maxRows: 50_000_000 },
|
|
56
|
+
|
|
57
|
+
// -- Cart abandonment (opt-in, since 0.8.0) --
|
|
58
|
+
// Storefront must fire cart_snapshot events (see below).
|
|
59
|
+
abandonment: {
|
|
60
|
+
windowMinutes: 30,
|
|
61
|
+
slackMinValueMinor: 5000,
|
|
62
|
+
slackWebhookUrl: process.env.HULO_ABANDONMENT_SLACK_URL,
|
|
63
|
+
recoveryLinkSecret: process.env.HULO_ABANDONMENT_SECRET,
|
|
64
|
+
recoveryLinkTtlHours: 72,
|
|
65
|
+
storefrontBaseUrl: 'https://shop.example.com',
|
|
66
|
+
},
|
|
48
67
|
}),
|
|
49
68
|
],
|
|
50
69
|
};
|
|
51
70
|
```
|
|
52
71
|
|
|
53
72
|
Add `VisitorAnalyticsPlugin.uiExtensions` to your `compileUiExtensions`
|
|
54
|
-
config.
|
|
73
|
+
config to pick up the Abandoned Carts + Analytics Insights admin pages.
|
|
74
|
+
|
|
75
|
+
## Storefront helpers
|
|
76
|
+
|
|
77
|
+
The plugin ships a **drop-in JS helper** at `/ees/hulo.js` — one script
|
|
78
|
+
tag and every event API below is available on `window.hulo`. It handles
|
|
79
|
+
batching, `sendBeacon` on unload, auto rage-click + dead-click
|
|
80
|
+
detection, and an on-mount `pageview`. Bare minimum:
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<script src="https://shop.example.com/ees/hulo.js" defer></script>
|
|
84
|
+
```
|
|
55
85
|
|
|
56
|
-
|
|
86
|
+
For a first-party integration (recommended — one bundle instead of a
|
|
87
|
+
second script tag), copy the equivalent typed helpers into your
|
|
88
|
+
storefront. The [elite.charity Qwik storefront](https://elite-software.co.uk)
|
|
89
|
+
uses this pattern. Every helper below is a thin wrapper around
|
|
90
|
+
`POST /ees/track` with a specific `meta.eventType` — the plugin's
|
|
91
|
+
server-side scanners look those event types up by name.
|
|
92
|
+
|
|
93
|
+
| Helper | When to call | What it feeds |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| `hulo.pageview()` | first mount + every route change | pageview funnel, exit-page report |
|
|
96
|
+
| `hulo.productView(productId, variantId?)` | on the PDP | co-view aggregation, `also-viewed`, `trending`, `personal` recs |
|
|
97
|
+
| `hulo.addToCart(variantId, qty, unitPriceMinor)` | on the "add" button | search-to-cart conversion |
|
|
98
|
+
| `hulo.cartSnapshot({ currency, totalMinor, itemCount, items, email? })` | every cart change (add / remove / qty) | **cart abandonment detection** |
|
|
99
|
+
| `hulo.search(query, resultsCount)` | on every executed search | top-queries, zero-result queries |
|
|
100
|
+
| `hulo.checkoutCompleted(orderCode, totalMinor)` | on the thank-you page | closes any open `abandoned_cart` row for this session |
|
|
101
|
+
| `hulo.rageClick(selector)` / `hulo.deadClick(selector)` | fire yourself if you have a better signal than the auto-detector | rage-click / dead-click hot-spot lists |
|
|
102
|
+
| `hulo.restoreCart(token)` | on your `/cart/restore?t=...` route | rebuild a cart from a signed recovery link |
|
|
103
|
+
|
|
104
|
+
Full payload shapes:
|
|
57
105
|
|
|
58
106
|
```ts
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
75
|
-
scheduleFlush();
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function scheduleFlush() {
|
|
79
|
-
clearTimeout(flushTimer);
|
|
80
|
-
flushTimer = setTimeout(flush, 1000);
|
|
81
|
-
}
|
|
82
|
-
function flush() {
|
|
83
|
-
if (!queue.length) return;
|
|
84
|
-
const body = JSON.stringify({ channelId: CHANNEL_ID, events: queue });
|
|
85
|
-
queue = [];
|
|
86
|
-
navigator.sendBeacon?.(ENDPOINT, body) ||
|
|
87
|
-
fetch(ENDPOINT, {
|
|
88
|
-
method: 'POST', body,
|
|
89
|
-
headers: { 'content-type': 'application/json' }, keepalive: true,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
107
|
+
hulo.cartSnapshot({
|
|
108
|
+
currency: 'GBP', // ISO-4217
|
|
109
|
+
totalMinor: 4995, // in pence / cents
|
|
110
|
+
itemCount: 2,
|
|
111
|
+
items: [
|
|
112
|
+
{ variantId: 42, name: 'Blue T-shirt (M)', qty: 1, unitPriceMinor: 1995, sku: 'BT-M' },
|
|
113
|
+
{ variantId: 88, name: 'Wool socks', qty: 1, unitPriceMinor: 3000 },
|
|
114
|
+
],
|
|
115
|
+
email: 'buyer@example.com', // optional — captured at checkout step 1
|
|
116
|
+
countryCode: 'GB', // optional
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
hulo.productView(product.id, selectedVariant.id);
|
|
120
|
+
hulo.search('rgb keyboard', 42); // (query, resultsCount)
|
|
121
|
+
hulo.checkoutCompleted('S2BZ54TEK', 12500); // (orderCode, totalMinor)
|
|
92
122
|
```
|
|
93
123
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
### Cart-restore route
|
|
125
|
+
|
|
126
|
+
The recovery link the admin mints (see below) lands on
|
|
127
|
+
`https://shop.example.com/cart/restore?t=<token>`. Your storefront
|
|
128
|
+
needs a route that:
|
|
129
|
+
|
|
130
|
+
1. Reads `?t=` from the URL
|
|
131
|
+
2. Calls `GET /ees/recover-cart?t=<token>` to fetch `{ items: [...] }`
|
|
132
|
+
3. Re-adds each `{ variantId, qty }` via your Vendure order API (usually
|
|
133
|
+
`addItemToOrder(productVariantId, quantity)`)
|
|
134
|
+
4. Navigates to `/cart` when done
|
|
135
|
+
|
|
136
|
+
Guard against silently overwriting a live cart — if the visitor
|
|
137
|
+
already has items, show a "you already have items in your cart"
|
|
138
|
+
message and let them reconcile. See
|
|
139
|
+
[elite.charity's `src/routes/cart/restore/index.tsx`](https://github.com/exceeded/elite-software-frontend/blob/main/src/routes/cart/restore/index.tsx)
|
|
140
|
+
for a working reference implementation.
|
|
141
|
+
|
|
142
|
+
### Legacy: hand-rolled tracker
|
|
143
|
+
|
|
144
|
+
If you prefer to skip `/ees/hulo.js`, the raw POST shape is unchanged:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const body = JSON.stringify({
|
|
148
|
+
channelId: 1,
|
|
149
|
+
events: [{
|
|
150
|
+
type: 'event',
|
|
151
|
+
url: location.href,
|
|
152
|
+
meta: { eventType: 'product_view', productId: 42 },
|
|
153
|
+
}],
|
|
154
|
+
});
|
|
155
|
+
navigator.sendBeacon('/ees/track', body) ||
|
|
156
|
+
fetch('/ees/track', {
|
|
157
|
+
method: 'POST', body, credentials: 'include',
|
|
158
|
+
headers: { 'content-type': 'application/json' }, keepalive: true,
|
|
159
|
+
});
|
|
160
|
+
```
|
|
97
161
|
|
|
98
162
|
## Feature tour
|
|
99
163
|
|
|
@@ -143,6 +207,89 @@ Stats at `GET /ees/goals/stats?days=30&channelId=1`.
|
|
|
143
207
|
body field or an `ees_consent=1` cookie before ingest.
|
|
144
208
|
- `dropBotEvents: false` (default) — flip on to skip bot UAs entirely.
|
|
145
209
|
|
|
210
|
+
### Cart abandonment (since 0.8.0)
|
|
211
|
+
|
|
212
|
+
Detects sessions that got as far as putting items in the cart but
|
|
213
|
+
never fired `checkout_completed`. Turns them into `AbandonedCart` rows
|
|
214
|
+
you can send a recovery email against.
|
|
215
|
+
|
|
216
|
+
**How detection works.**
|
|
217
|
+
The plugin runs a worker-only sweep every 5 minutes. It looks at every
|
|
218
|
+
session that fired at least one `cart_snapshot` event, and:
|
|
219
|
+
|
|
220
|
+
- If a `checkout_completed` landed later — do nothing (or if an
|
|
221
|
+
`abandoned_cart` row already exists, promote it to `converted`).
|
|
222
|
+
- If the last `cart_snapshot` is older than `abandonment.windowMinutes`
|
|
223
|
+
(default 30) — open an `abandoned_cart` row, keyed on `sessionId`
|
|
224
|
+
(unique — you can't double-open the same session).
|
|
225
|
+
- Otherwise leave the session alone. It may still convert.
|
|
226
|
+
|
|
227
|
+
**Recovery link.**
|
|
228
|
+
`POST /ees/abandoned-carts/:id/recovery-link` mints a signed opaque
|
|
229
|
+
token and returns `{ ok: true, url: '<storefront>/cart/restore?t=...' }`.
|
|
230
|
+
The token is time-bounded (`recoveryLinkTtlHours`, default 72) and
|
|
231
|
+
non-reusable. The storefront exchanges it via
|
|
232
|
+
`GET /ees/recover-cart?t=<token>` to get back the persisted item list.
|
|
233
|
+
|
|
234
|
+
Set `abandonment.recoveryLinkSecret` in plugin options to enable this —
|
|
235
|
+
without it, the endpoint returns `{ error: 'recovery-disabled-or-not-found' }`.
|
|
236
|
+
|
|
237
|
+
**Slack notification.**
|
|
238
|
+
`abandonment.slackWebhookUrl` + `abandonment.slackMinValueMinor`
|
|
239
|
+
control an at-most-once Slack post per abandonment above the value
|
|
240
|
+
threshold. Useful for sales teams that follow up on high-value drops
|
|
241
|
+
manually.
|
|
242
|
+
|
|
243
|
+
**Admin dashboard.**
|
|
244
|
+
Under **Analytics → Abandoned carts**. Filters by status / min value /
|
|
245
|
+
email / window. Actions per row: mint recovery link (copies URL to
|
|
246
|
+
clipboard), mark recovered manually, dismiss. CSV export.
|
|
247
|
+
|
|
248
|
+
### Product recommendations (since 0.8.0)
|
|
249
|
+
|
|
250
|
+
A `ProductCoView` aggregate table holds a per-triple counter
|
|
251
|
+
`(productIdA, productIdB, channelId) → viewsTogether`. Rebuilt every 6
|
|
252
|
+
hours from the last 24h of `product_view` events, bounded to 20 events
|
|
253
|
+
per session so runaway bot sessions can't skew the table.
|
|
254
|
+
Denormalised — we store both `(A, B)` and `(B, A)` — so read-side
|
|
255
|
+
lookups are one indexed scan.
|
|
256
|
+
|
|
257
|
+
Three endpoints, all safe from the storefront (no PII):
|
|
258
|
+
|
|
259
|
+
| Endpoint | Use |
|
|
260
|
+
| --- | --- |
|
|
261
|
+
| `GET /ees/recommendations/also-viewed?productId=42&limit=10` | product-page rail: "customers who viewed X also viewed…" |
|
|
262
|
+
| `GET /ees/recommendations/personal?visitorId=abc&limit=10` | homepage / cart recs for a returning visitor. Uses their last 10 `product_view` events over 30 days, excludes the seeds so the same product never appears |
|
|
263
|
+
| `GET /ees/recommendations/trending?hours=24&limit=10` | homepage rail: most-viewed products in the window. Reflects real intent (not search-console clicks) |
|
|
264
|
+
|
|
265
|
+
`GET /ees/recommendations/aggregate-now` (SuperAdmin only) forces a
|
|
266
|
+
sweep — useful after a big backfill or spike.
|
|
267
|
+
|
|
268
|
+
### Site search analytics (since 0.8.0)
|
|
269
|
+
|
|
270
|
+
Zero-schema-cost queries over the existing `visitor_event` table where
|
|
271
|
+
the storefront has fired `hulo.search(query, resultsCount)` events.
|
|
272
|
+
|
|
273
|
+
| Endpoint | Use |
|
|
274
|
+
| --- | --- |
|
|
275
|
+
| `GET /ees/search-analytics/top?days=7` | top queries by volume with average results count |
|
|
276
|
+
| `GET /ees/search-analytics/no-results?days=7` | queries that returned zero hits — direct catalogue-gap intel |
|
|
277
|
+
| `GET /ees/search-analytics/conversion?days=7` | of sessions that searched, what fraction went on to `add_to_cart` |
|
|
278
|
+
|
|
279
|
+
### Journey drawer buffs (since 0.8.0)
|
|
280
|
+
|
|
281
|
+
| Endpoint | Use |
|
|
282
|
+
| --- | --- |
|
|
283
|
+
| `GET /ees/journey/rage-clicks?days=7` | rage-click hot-spot list per URL. Pages where visitors are frustrated |
|
|
284
|
+
| `GET /ees/journey/dead-clicks?days=7` | dead-click hot-spot list per URL. Elements that LOOK clickable but aren't |
|
|
285
|
+
| `GET /ees/journey/session-summary?visitorId=abc` | per-session summary with a heuristic `intent` label (`purchase` / `abandon` / `frustrate` / `consider` / `browse` / `bounce`) |
|
|
286
|
+
|
|
287
|
+
Rage-click auto-detector fires on ≥3 pointerdown events within 500ms
|
|
288
|
+
and a 20-pixel radius. Dead-click auto-detector fires when a click
|
|
289
|
+
lands on a non-interactive element and no navigation / significant
|
|
290
|
+
scroll follows within 400ms. Both are conservative heuristics — the
|
|
291
|
+
signal is direction-of-frustration, not a metric to optimise against.
|
|
292
|
+
|
|
146
293
|
### Live-now widget
|
|
147
294
|
|
|
148
295
|
SSE stream at `GET /ees/visitors/live` pushes the active-visitor count
|
|
@@ -160,25 +307,52 @@ events with full enrichment.
|
|
|
160
307
|
|
|
161
308
|
## HTTP endpoints
|
|
162
309
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
|
167
|
-
|
|
|
168
|
-
| `
|
|
169
|
-
| `GET`
|
|
170
|
-
| `GET`
|
|
171
|
-
| `GET`
|
|
172
|
-
| `GET`
|
|
173
|
-
| `GET`
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
|
179
|
-
|
|
|
180
|
-
| `GET`
|
|
181
|
-
| `GET`
|
|
310
|
+
**Public** (no auth — CORS-permissive for browser calls from any
|
|
311
|
+
storefront origin):
|
|
312
|
+
|
|
313
|
+
| Method | Path | Purpose |
|
|
314
|
+
| --- | --- | --- |
|
|
315
|
+
| `POST` | `/ees/track` | ingest a batch of visitor events |
|
|
316
|
+
| `GET` | `/ees/hulo.js` | typed storefront helper JS (since 0.8.1) |
|
|
317
|
+
| `GET` | `/ees/recover-cart?t=<token>` | resolve a recovery token → items |
|
|
318
|
+
| `GET` | `/ees/recommendations/also-viewed?productId=…` | co-view recs |
|
|
319
|
+
| `GET` | `/ees/recommendations/personal?visitorId=…` | personalised recs |
|
|
320
|
+
| `GET` | `/ees/recommendations/trending?hours=…` | most-viewed products |
|
|
321
|
+
|
|
322
|
+
**Admin** (Vendure `ReadCustomer` unless noted; requires a
|
|
323
|
+
Vendure admin session cookie):
|
|
324
|
+
|
|
325
|
+
| Method | Path | Purpose |
|
|
326
|
+
| --- | --- | --- |
|
|
327
|
+
| `GET` | `/ees/visitors/summary` | top-line + daily series |
|
|
328
|
+
| `GET` | `/ees/visitors/sources` | top sources by visits |
|
|
329
|
+
| `GET` | `/ees/visitors/top-pages` | most-visited URLs |
|
|
330
|
+
| `GET` | `/ees/visitors/funnel` | configurable funnel |
|
|
331
|
+
| `GET` | `/ees/visitors/exit-pages` | top exit pages |
|
|
332
|
+
| `GET` | `/ees/visitors/top-events` | top custom events |
|
|
333
|
+
| `GET` | `/ees/visitors/live` | SSE live-now stream |
|
|
334
|
+
| `GET` | `/ees/visitors/journey/:visitorId` | per-visitor timeline |
|
|
335
|
+
| `GET` | `/ees/visitors/recent` | recent events |
|
|
336
|
+
| `GET` | `/ees/visitors/export.csv` | CSV export |
|
|
337
|
+
| `GET` | `/ees/goals` | list conversion goals |
|
|
338
|
+
| `POST` | `/ees/goals` | create a goal |
|
|
339
|
+
| `PUT` | `/ees/goals/:id` | update a goal |
|
|
340
|
+
| `DELETE`| `/ees/goals/:id` | delete a goal |
|
|
341
|
+
| `GET` | `/ees/goals/stats` | per-goal completion stats |
|
|
342
|
+
| `GET` | `/ees/visitors/status` | version + update status |
|
|
343
|
+
| `GET` | `/ees/abandoned-carts` | paginated list w/ filters (0.8.0) |
|
|
344
|
+
| `GET` | `/ees/abandoned-carts/summary` | totals + recovery rate (0.8.0) |
|
|
345
|
+
| `GET` | `/ees/abandoned-carts/:id` | detail incl. parsed items (0.8.0) |
|
|
346
|
+
| `POST` | `/ees/abandoned-carts/:id/recovery-link` | mint signed URL (0.8.0, `UpdateCustomer`) |
|
|
347
|
+
| `POST` | `/ees/abandoned-carts/:id/status` | mark recovered/dismissed (0.8.0, `UpdateCustomer`) |
|
|
348
|
+
| `GET` | `/ees/abandoned-carts/export.csv` | CSV export (0.8.0) |
|
|
349
|
+
| `GET` | `/ees/recommendations/aggregate-now` | force co-view sweep (0.8.0, `SuperAdmin`) |
|
|
350
|
+
| `GET` | `/ees/search-analytics/top` | top queries (0.8.0) |
|
|
351
|
+
| `GET` | `/ees/search-analytics/no-results` | zero-result queries (0.8.0) |
|
|
352
|
+
| `GET` | `/ees/search-analytics/conversion` | search→cart rate (0.8.0) |
|
|
353
|
+
| `GET` | `/ees/journey/rage-clicks` | rage-click hot spots (0.8.0) |
|
|
354
|
+
| `GET` | `/ees/journey/dead-clicks` | dead-click hot spots (0.8.0) |
|
|
355
|
+
| `GET` | `/ees/journey/session-summary?visitorId=…` | per-session intent labels (0.8.0) |
|
|
182
356
|
|
|
183
357
|
## Documentation
|
|
184
358
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single-file storefront helper the plugin serves at /ees/hulo.js.
|
|
3
|
+
*
|
|
4
|
+
* Written as a string constant (not a bundled module) because:
|
|
5
|
+
* - It's tiny (< 6kB minified) — a bundler adds more than the code
|
|
6
|
+
* - It ships to the browser verbatim, no compile step
|
|
7
|
+
* - Customers can inspect / audit it without a source map
|
|
8
|
+
* - It has ZERO npm dependencies at runtime
|
|
9
|
+
*
|
|
10
|
+
* The controller wraps this in a small template that injects the
|
|
11
|
+
* public base URL and a default channel id, so a storefront paste
|
|
12
|
+
* of `<script src="https://backend/ees/hulo.js"></script>` Just Works
|
|
13
|
+
* with no config. Advanced users can call `hulo.configure({ ... })`
|
|
14
|
+
* to override.
|
|
15
|
+
*/
|
|
16
|
+
export declare const HULO_STOREFRONT_JS: (backendBaseUrl: string, defaultChannelId?: number) => string;
|
|
17
|
+
//# sourceMappingURL=hulo-storefront.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hulo-storefront.d.ts","sourceRoot":"","sources":["../src/hulo-storefront.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kBAAkB,GAAI,gBAAgB,MAAM,EAAE,yBAAoB,KAAG,MA4QjF,CAAC"}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HULO_STOREFRONT_JS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The single-file storefront helper the plugin serves at /ees/hulo.js.
|
|
6
|
+
*
|
|
7
|
+
* Written as a string constant (not a bundled module) because:
|
|
8
|
+
* - It's tiny (< 6kB minified) — a bundler adds more than the code
|
|
9
|
+
* - It ships to the browser verbatim, no compile step
|
|
10
|
+
* - Customers can inspect / audit it without a source map
|
|
11
|
+
* - It has ZERO npm dependencies at runtime
|
|
12
|
+
*
|
|
13
|
+
* The controller wraps this in a small template that injects the
|
|
14
|
+
* public base URL and a default channel id, so a storefront paste
|
|
15
|
+
* of `<script src="https://backend/ees/hulo.js"></script>` Just Works
|
|
16
|
+
* with no config. Advanced users can call `hulo.configure({ ... })`
|
|
17
|
+
* to override.
|
|
18
|
+
*/
|
|
19
|
+
const HULO_STOREFRONT_JS = (backendBaseUrl, defaultChannelId = 1) => `
|
|
20
|
+
// hulo storefront helper — @huloglobal/vendure-plugin-visitor-analytics
|
|
21
|
+
// Public domain when served from your own store; do not resell.
|
|
22
|
+
(function (global) {
|
|
23
|
+
var CONFIG = {
|
|
24
|
+
endpoint: ${JSON.stringify(backendBaseUrl.replace(/\/$/, '') + '/ees/track')},
|
|
25
|
+
recoverEndpoint: ${JSON.stringify(backendBaseUrl.replace(/\/$/, '') + '/ees/recover-cart')},
|
|
26
|
+
channelId: ${defaultChannelId},
|
|
27
|
+
debug: false,
|
|
28
|
+
autoRageClick: true,
|
|
29
|
+
autoDeadClick: true,
|
|
30
|
+
autoScroll: false, // not implemented yet — flag reserved
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ── queue + batched send ─────────────────────────────────────────
|
|
34
|
+
// Every helper below pushes into a queue. Flush on requestIdleCallback
|
|
35
|
+
// or 2s max. Coalesces bursts of events into one HTTP roundtrip.
|
|
36
|
+
var QUEUE = [];
|
|
37
|
+
var FLUSH_TIMER = null;
|
|
38
|
+
function enqueue(event) {
|
|
39
|
+
event.url = event.url || location.href;
|
|
40
|
+
event.channelId = event.channelId || CONFIG.channelId;
|
|
41
|
+
QUEUE.push(event);
|
|
42
|
+
scheduleFlush();
|
|
43
|
+
if (CONFIG.debug) console.log('[hulo]', event);
|
|
44
|
+
}
|
|
45
|
+
function scheduleFlush() {
|
|
46
|
+
if (FLUSH_TIMER) return;
|
|
47
|
+
FLUSH_TIMER = setTimeout(flush, 1500);
|
|
48
|
+
}
|
|
49
|
+
function flush() {
|
|
50
|
+
FLUSH_TIMER = null;
|
|
51
|
+
if (!QUEUE.length) return;
|
|
52
|
+
var batch = QUEUE.splice(0, QUEUE.length);
|
|
53
|
+
var payload = JSON.stringify({ events: batch });
|
|
54
|
+
// Prefer sendBeacon on unload (survives page transitions) and
|
|
55
|
+
// fall back to fetch keepalive for normal calls.
|
|
56
|
+
try {
|
|
57
|
+
if (navigator.sendBeacon) {
|
|
58
|
+
var blob = new Blob([payload], { type: 'application/json' });
|
|
59
|
+
if (navigator.sendBeacon(CONFIG.endpoint, blob)) return;
|
|
60
|
+
}
|
|
61
|
+
} catch (_e) {}
|
|
62
|
+
try {
|
|
63
|
+
fetch(CONFIG.endpoint, {
|
|
64
|
+
method: 'POST', credentials: 'include', keepalive: true,
|
|
65
|
+
headers: { 'content-type': 'application/json' },
|
|
66
|
+
body: payload,
|
|
67
|
+
}).catch(function () { /* silent */ });
|
|
68
|
+
} catch (_e) {}
|
|
69
|
+
}
|
|
70
|
+
// Flush before the page dies.
|
|
71
|
+
window.addEventListener('pagehide', flush, { capture: true });
|
|
72
|
+
window.addEventListener('beforeunload', flush, { capture: true });
|
|
73
|
+
|
|
74
|
+
// ── public API ───────────────────────────────────────────────────
|
|
75
|
+
var hulo = {
|
|
76
|
+
configure: function (opts) { Object.assign(CONFIG, opts || {}); },
|
|
77
|
+
flush: flush,
|
|
78
|
+
|
|
79
|
+
/** Fire once per pageview. Optional — the ingest endpoint infers
|
|
80
|
+
* pageviews from the referrer / URL, but calling this explicitly
|
|
81
|
+
* captures the title too. */
|
|
82
|
+
pageview: function (extra) {
|
|
83
|
+
enqueue(Object.assign({ type: 'pageview', title: document.title }, extra || {}));
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** Product detail viewed. Store the product id you would use to
|
|
87
|
+
* fetch the product server-side (Vendure product.id). If your
|
|
88
|
+
* storefront uses variants primarily, pass variantId too. */
|
|
89
|
+
productView: function (productId, productVariantId) {
|
|
90
|
+
if (!productId && productId !== 0) return;
|
|
91
|
+
enqueue({
|
|
92
|
+
type: 'event',
|
|
93
|
+
meta: JSON.stringify({
|
|
94
|
+
eventType: 'product_view',
|
|
95
|
+
productId: Number(productId),
|
|
96
|
+
productVariantId: productVariantId != null ? Number(productVariantId) : undefined,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/** Item added to cart. Fire on every add — not just the first. */
|
|
102
|
+
addToCart: function (variantId, qty, unitPriceMinor) {
|
|
103
|
+
enqueue({
|
|
104
|
+
type: 'event',
|
|
105
|
+
meta: JSON.stringify({
|
|
106
|
+
eventType: 'add_to_cart',
|
|
107
|
+
variantId: variantId != null ? Number(variantId) : undefined,
|
|
108
|
+
qty: qty != null ? Number(qty) : 1,
|
|
109
|
+
unitPriceMinor: unitPriceMinor != null ? Number(unitPriceMinor) : undefined,
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/** Cart snapshot — fire on every cart change (add / remove / qty).
|
|
115
|
+
* The abandonment scanner reads only this event, so fire it
|
|
116
|
+
* liberally. Payload shape:
|
|
117
|
+
* { currency, totalMinor, itemCount, items: [{ variantId, name, qty, unitPriceMinor }], email? } */
|
|
118
|
+
cartSnapshot: function (cart) {
|
|
119
|
+
if (!cart || !cart.items || !cart.items.length) return;
|
|
120
|
+
enqueue({
|
|
121
|
+
type: 'event',
|
|
122
|
+
meta: JSON.stringify({
|
|
123
|
+
eventType: 'cart_snapshot',
|
|
124
|
+
currency: (cart.currency || 'GBP').toUpperCase().slice(0, 3),
|
|
125
|
+
totalMinor: Number(cart.totalMinor || 0),
|
|
126
|
+
itemCount: Number(cart.itemCount || cart.items.length),
|
|
127
|
+
items: cart.items.map(function (i) {
|
|
128
|
+
return {
|
|
129
|
+
variantId: i.variantId != null ? Number(i.variantId) : undefined,
|
|
130
|
+
productId: i.productId != null ? Number(i.productId) : undefined,
|
|
131
|
+
name: i.name != null ? String(i.name).slice(0, 200) : undefined,
|
|
132
|
+
qty: Number(i.qty != null ? i.qty : (i.quantity != null ? i.quantity : 1)),
|
|
133
|
+
unitPriceMinor: Number(i.unitPriceMinor != null ? i.unitPriceMinor : (i.unitPrice != null ? i.unitPrice : 0)),
|
|
134
|
+
sku: i.sku != null ? String(i.sku).slice(0, 100) : undefined,
|
|
135
|
+
};
|
|
136
|
+
}).slice(0, 100),
|
|
137
|
+
email: cart.email || undefined,
|
|
138
|
+
countryCode: cart.countryCode || undefined,
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/** Storefront site search executed. Pass the raw query + the
|
|
144
|
+
* resulting hit count so no-result queries can be aggregated. */
|
|
145
|
+
search: function (query, resultsCount) {
|
|
146
|
+
var q = String(query || '').trim();
|
|
147
|
+
if (!q) return;
|
|
148
|
+
enqueue({
|
|
149
|
+
type: 'event',
|
|
150
|
+
meta: JSON.stringify({
|
|
151
|
+
eventType: 'search',
|
|
152
|
+
query: q.slice(0, 200),
|
|
153
|
+
resultsCount: Math.max(0, Number(resultsCount || 0)),
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/** Checkout finished — fires the counter that closes any open
|
|
159
|
+
* abandoned-cart row for this session. Call from your thank-you
|
|
160
|
+
* page. Optionally pass the order code + total. */
|
|
161
|
+
checkoutCompleted: function (orderCode, totalMinor) {
|
|
162
|
+
enqueue({
|
|
163
|
+
type: 'event',
|
|
164
|
+
meta: JSON.stringify({
|
|
165
|
+
eventType: 'checkout_completed',
|
|
166
|
+
orderCode: orderCode ? String(orderCode).slice(0, 64) : undefined,
|
|
167
|
+
totalMinor: totalMinor != null ? Number(totalMinor) : undefined,
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
flush(); // don't wait — the visitor may close the tab immediately
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/** Rage-click event — fire it yourself if you have a better signal
|
|
174
|
+
* than the built-in detector. */
|
|
175
|
+
rageClick: function (selector, extraMeta) {
|
|
176
|
+
enqueue({
|
|
177
|
+
type: 'event',
|
|
178
|
+
meta: JSON.stringify(Object.assign({
|
|
179
|
+
eventType: 'rage_click',
|
|
180
|
+
selector: selector ? String(selector).slice(0, 200) : undefined,
|
|
181
|
+
}, extraMeta || {})),
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/** Click landed on a non-interactive element. */
|
|
186
|
+
deadClick: function (selector, extraMeta) {
|
|
187
|
+
enqueue({
|
|
188
|
+
type: 'event',
|
|
189
|
+
meta: JSON.stringify(Object.assign({
|
|
190
|
+
eventType: 'dead_click',
|
|
191
|
+
selector: selector ? String(selector).slice(0, 200) : undefined,
|
|
192
|
+
}, extraMeta || {})),
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Restore a cart from a signed recovery token. Fetches the items
|
|
198
|
+
* from the backend and returns them. Your storefront is responsible
|
|
199
|
+
* for calling its own Vendure order API to actually re-add them,
|
|
200
|
+
* because different storefronts frame that call differently.
|
|
201
|
+
*/
|
|
202
|
+
restoreCart: function (token) {
|
|
203
|
+
var t = String(token || '').trim();
|
|
204
|
+
if (!t) return Promise.reject(new Error('missing-token'));
|
|
205
|
+
return fetch(CONFIG.recoverEndpoint + '?t=' + encodeURIComponent(t), {
|
|
206
|
+
credentials: 'include',
|
|
207
|
+
}).then(function (r) { return r.json(); });
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// ── auto rage-click detector ─────────────────────────────────────
|
|
212
|
+
// Any pointerdown ≥ 3 within 500ms in a 20x20px zone counts.
|
|
213
|
+
if (CONFIG.autoRageClick) {
|
|
214
|
+
var recent = [];
|
|
215
|
+
document.addEventListener('pointerdown', function (e) {
|
|
216
|
+
var now = Date.now();
|
|
217
|
+
recent = recent.filter(function (r) { return now - r.t < 500; });
|
|
218
|
+
recent.push({ t: now, x: e.clientX, y: e.clientY, target: e.target });
|
|
219
|
+
var close = recent.filter(function (r) {
|
|
220
|
+
return Math.abs(r.x - e.clientX) < 20 && Math.abs(r.y - e.clientY) < 20;
|
|
221
|
+
});
|
|
222
|
+
if (close.length >= 3) {
|
|
223
|
+
hulo.rageClick(cssPathOf(e.target));
|
|
224
|
+
recent = []; // reset so we don't fire again on the 4th, 5th, etc.
|
|
225
|
+
}
|
|
226
|
+
}, { capture: true, passive: true });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── auto dead-click detector ─────────────────────────────────────
|
|
230
|
+
// A click that lands on an element with no href / not a button /
|
|
231
|
+
// not a form control and doesn't produce a navigation within 400ms.
|
|
232
|
+
if (CONFIG.autoDeadClick) {
|
|
233
|
+
document.addEventListener('click', function (e) {
|
|
234
|
+
var target = e.target;
|
|
235
|
+
if (!target || target.nodeType !== 1) return;
|
|
236
|
+
if (isInteractive(target)) return;
|
|
237
|
+
var beforeUrl = location.href;
|
|
238
|
+
var beforeScroll = window.scrollY;
|
|
239
|
+
setTimeout(function () {
|
|
240
|
+
if (location.href !== beforeUrl) return;
|
|
241
|
+
if (Math.abs(window.scrollY - beforeScroll) > 50) return;
|
|
242
|
+
// Was there an ancestor with a click handler? Best-effort:
|
|
243
|
+
// walk up and check for onclick, role='button', tabindex.
|
|
244
|
+
var node = target;
|
|
245
|
+
for (var i = 0; i < 4 && node; i++) {
|
|
246
|
+
if (isInteractive(node) || node.onclick) return;
|
|
247
|
+
node = node.parentElement;
|
|
248
|
+
}
|
|
249
|
+
hulo.deadClick(cssPathOf(target));
|
|
250
|
+
}, 400);
|
|
251
|
+
}, { capture: true, passive: true });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isInteractive(el) {
|
|
255
|
+
if (!el || !el.tagName) return false;
|
|
256
|
+
var tag = el.tagName.toLowerCase();
|
|
257
|
+
if (tag === 'a' || tag === 'button' || tag === 'input' || tag === 'select'
|
|
258
|
+
|| tag === 'textarea' || tag === 'label' || tag === 'summary'
|
|
259
|
+
|| tag === 'details') return true;
|
|
260
|
+
if (el.getAttribute && (el.getAttribute('role') === 'button'
|
|
261
|
+
|| el.getAttribute('role') === 'link'
|
|
262
|
+
|| el.hasAttribute('tabindex'))) return true;
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function cssPathOf(el) {
|
|
267
|
+
if (!el || el.nodeType !== 1) return '';
|
|
268
|
+
var parts = [];
|
|
269
|
+
while (el && el.nodeType === 1 && parts.length < 5) {
|
|
270
|
+
var p = el.tagName.toLowerCase();
|
|
271
|
+
if (el.id) { p += '#' + el.id; parts.unshift(p); break; }
|
|
272
|
+
if (el.className && typeof el.className === 'string') {
|
|
273
|
+
var cls = el.className.trim().split(/\\s+/).slice(0, 2).join('.');
|
|
274
|
+
if (cls) p += '.' + cls;
|
|
275
|
+
}
|
|
276
|
+
parts.unshift(p);
|
|
277
|
+
el = el.parentElement;
|
|
278
|
+
}
|
|
279
|
+
return parts.join(' > ');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
global.hulo = hulo;
|
|
283
|
+
// Auto pageview on load — most callers want it.
|
|
284
|
+
if (document.readyState === 'complete') hulo.pageview();
|
|
285
|
+
else window.addEventListener('load', function () { hulo.pageview(); });
|
|
286
|
+
})(window);
|
|
287
|
+
`;
|
|
288
|
+
exports.HULO_STOREFRONT_JS = HULO_STOREFRONT_JS;
|
|
289
|
+
//# sourceMappingURL=hulo-storefront.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hulo-storefront.js","sourceRoot":"","sources":["../src/hulo-storefront.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;GAcG;AACI,MAAM,kBAAkB,GAAG,CAAC,cAAsB,EAAE,gBAAgB,GAAG,CAAC,EAAU,EAAE,CAAC;;;;;gBAK5E,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,YAAY,CAAC;uBACzD,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,mBAAmB,CAAC;iBAC7E,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqQhC,CAAC;AA5QW,QAAA,kBAAkB,sBA4Q7B"}
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,IAAI,EAAiB,MAAM,eAAe,CAAC;AACxE,OAAO,EAAmC,aAAa,EAAE,gBAAgB,EAAqB,aAAa,EAA4C,MAAM,iCAAiC,CAAC;AAM/L,OAAO,EAAwB,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,IAAI,EAAiB,MAAM,eAAe,CAAC;AACxE,OAAO,EAAmC,aAAa,EAAE,gBAAgB,EAAqB,aAAa,EAA4C,MAAM,iCAAiC,CAAC;AAM/L,OAAO,EAAwB,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAiBpF,MAAM,WAAW,6BAA6B;IAC1C,qEAAqE;IACrE,aAAa,EAAE,MAAM,CAAC;IACtB;;;gDAG4C;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;IAGpB;6EACyE;IACzE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;yEAEqE;IACrE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;qEAEiE;IACjE,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;6CAEyC;IACzC,aAAa,CAAC,EAAE,OAAO,CAAC;IAGxB,mDAAmD;IACnD,SAAS,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD;;yEAEqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;2EAEuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;IAG9B,8DAA8D;IAC9D,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAG7B;;4DAEwD;IACxD,WAAW,CAAC,EAAE,kBAAkB,CAAC;CACpC;AAgBD,QAAA,MAAM,eAAe,EAAE,6BAMtB,CAAC;AAGF,wBAAgB,UAAU,IAAI,OAAO,eAAe,GAAG,6BAA6B,CAEnF;AAED;;;;;;;;;;;GAWG;AACH,qBA0Ba,sBAAsB;IAC/B,OAAO,CAAC,MAAM,CAAC,UAAU,CAAkC;IAC3D,OAAO,CAAC,MAAM,CAAC,aAAa,CAA8B;IAC1D,OAAO,CAAC,MAAM,CAAC,SAAS,CAA0B;IAClD,OAAO,CAAC,MAAM,CAAC,aAAa,CAA8B;IAE1D,MAAM,CAAC,gBAAgB,IAAI,aAAa,GAAG,IAAI;IAC/C,MAAM,CAAC,iBAAiB,IAAI,MAAM;IAClC,MAAM,CAAC,cAAc,IAAI,MAAM;IAC/B,MAAM,CAAC,gBAAgB,IAAI,aAAa,GAAG,IAAI;IAE/C,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,6BAA6B,GAAG,IAAI,CAAC,sBAAsB,CAAC;IAoDjF,MAAM,CAAC,YAAY;;;;;;;;MAUjB;CACL"}
|
package/dist/plugin.js
CHANGED
|
@@ -27,6 +27,7 @@ const abandoned_cart_controller_1 = require("./abandoned-cart.controller");
|
|
|
27
27
|
const recommendations_controller_1 = require("./recommendations.controller");
|
|
28
28
|
const search_analytics_controller_1 = require("./search-analytics.controller");
|
|
29
29
|
const journey_buffs_controller_1 = require("./journey-buffs.controller");
|
|
30
|
+
const storefront_helper_controller_1 = require("./storefront-helper.controller");
|
|
30
31
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
31
32
|
const PKG_VERSION = require('../package.json').version;
|
|
32
33
|
const PKG_NAME = '@huloglobal/vendure-plugin-visitor-analytics';
|
|
@@ -148,6 +149,7 @@ exports.VisitorAnalyticsPlugin = VisitorAnalyticsPlugin = VisitorAnalyticsPlugin
|
|
|
148
149
|
recommendations_controller_1.RecommendationsController,
|
|
149
150
|
search_analytics_controller_1.SearchAnalyticsController,
|
|
150
151
|
journey_buffs_controller_1.JourneyBuffsController,
|
|
152
|
+
storefront_helper_controller_1.StorefrontHelperController,
|
|
151
153
|
],
|
|
152
154
|
entities: [visitor_event_entity_1.VisitorEvent, conversion_goal_entity_1.ConversionGoal, abandoned_cart_entity_1.AbandonedCart, product_co_view_entity_1.ProductCoView],
|
|
153
155
|
compatibility: '^3.0.0',
|
package/dist/plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Request, Response } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Serves the storefront helper at `/ees/hulo.js`. Storefronts embed
|
|
4
|
+
* one script tag and every helper (cartSnapshot, productView, search,
|
|
5
|
+
* rageClick+deadClick auto-detectors, checkoutCompleted, restoreCart)
|
|
6
|
+
* is available on `window.hulo`.
|
|
7
|
+
*
|
|
8
|
+
* Cache: 10-minute browser TTL + 24-hour SWR. The file's content is
|
|
9
|
+
* a function of plugin options; storefronts don't need to bust the
|
|
10
|
+
* cache to see config changes because the URL stays stable and 10
|
|
11
|
+
* minutes is short enough that dashboard tweaks propagate promptly.
|
|
12
|
+
*/
|
|
13
|
+
export declare class StorefrontHelperController {
|
|
14
|
+
hulo(req: Request, res: Response): void;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=storefront-helper.controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storefront-helper.controller.d.ts","sourceRoot":"","sources":["../src/storefront-helper.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAI5C;;;;;;;;;;GAUG;AACH,qBACa,0BAA0B;IAEnC,IAAI,CAAQ,GAAG,EAAE,OAAO,EAAS,GAAG,EAAE,QAAQ;CAYjD"}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.StorefrontHelperController = void 0;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const hulo_storefront_1 = require("./hulo-storefront");
|
|
18
|
+
const plugin_1 = require("./plugin");
|
|
19
|
+
/**
|
|
20
|
+
* Serves the storefront helper at `/ees/hulo.js`. Storefronts embed
|
|
21
|
+
* one script tag and every helper (cartSnapshot, productView, search,
|
|
22
|
+
* rageClick+deadClick auto-detectors, checkoutCompleted, restoreCart)
|
|
23
|
+
* is available on `window.hulo`.
|
|
24
|
+
*
|
|
25
|
+
* Cache: 10-minute browser TTL + 24-hour SWR. The file's content is
|
|
26
|
+
* a function of plugin options; storefronts don't need to bust the
|
|
27
|
+
* cache to see config changes because the URL stays stable and 10
|
|
28
|
+
* minutes is short enough that dashboard tweaks propagate promptly.
|
|
29
|
+
*/
|
|
30
|
+
let StorefrontHelperController = class StorefrontHelperController {
|
|
31
|
+
hulo(req, res) {
|
|
32
|
+
const opts = (0, plugin_1.getOptions)();
|
|
33
|
+
const base = opts.publicBaseUrl
|
|
34
|
+
|| `https://${req.headers.host || 'localhost'}`;
|
|
35
|
+
const js = (0, hulo_storefront_1.HULO_STOREFRONT_JS)(base, 1);
|
|
36
|
+
res.setHeader('content-type', 'application/javascript; charset=utf-8');
|
|
37
|
+
res.setHeader('cache-control', 'public, max-age=600, stale-while-revalidate=86400');
|
|
38
|
+
// Storefront lives on a different origin from the backend —
|
|
39
|
+
// static-JS CORS is safe (no credentials) but be explicit.
|
|
40
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
41
|
+
res.send(js);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
exports.StorefrontHelperController = StorefrontHelperController;
|
|
45
|
+
__decorate([
|
|
46
|
+
(0, common_1.Get)('hulo.js'),
|
|
47
|
+
__param(0, (0, common_1.Req)()),
|
|
48
|
+
__param(1, (0, common_1.Res)()),
|
|
49
|
+
__metadata("design:type", Function),
|
|
50
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
51
|
+
__metadata("design:returntype", void 0)
|
|
52
|
+
], StorefrontHelperController.prototype, "hulo", null);
|
|
53
|
+
exports.StorefrontHelperController = StorefrontHelperController = __decorate([
|
|
54
|
+
(0, common_1.Controller)('ees')
|
|
55
|
+
], StorefrontHelperController);
|
|
56
|
+
//# sourceMappingURL=storefront-helper.controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storefront-helper.controller.js","sourceRoot":"","sources":["../src/storefront-helper.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAA2D;AAE3D,uDAAuD;AACvD,qCAAsC;AAEtC;;;;;;;;;;GAUG;AAEI,IAAM,0BAA0B,GAAhC,MAAM,0BAA0B;IAEnC,IAAI,CAAQ,GAAY,EAAS,GAAa;QAC1C,MAAM,IAAI,GAAG,IAAA,mBAAU,GAAE,CAAC;QAC1B,MAAM,IAAI,GAAI,IAAY,CAAC,aAAa;eACjC,WAAW,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,WAAW,EAAE,CAAC;QACpD,MAAM,EAAE,GAAG,IAAA,oCAAkB,EAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QACvC,GAAG,CAAC,SAAS,CAAC,cAAc,EAAE,uCAAuC,CAAC,CAAC;QACvE,GAAG,CAAC,SAAS,CAAC,eAAe,EAAE,mDAAmD,CAAC,CAAC;QACpF,4DAA4D;QAC5D,2DAA2D;QAC3D,GAAG,CAAC,SAAS,CAAC,6BAA6B,EAAE,GAAG,CAAC,CAAC;QAClD,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACjB,CAAC;CACJ,CAAA;AAdY,gEAA0B;AAEnC;IADC,IAAA,YAAG,EAAC,SAAS,CAAC;IACT,WAAA,IAAA,YAAG,GAAE,CAAA;IAAgB,WAAA,IAAA,YAAG,GAAE,CAAA;;;;sDAW/B;qCAbQ,0BAA0B;IADtC,IAAA,mBAAU,EAAC,KAAK,CAAC;GACL,0BAA0B,CActC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huloglobal/vendure-plugin-visitor-analytics",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Full-funnel visitor analytics for Vendure storefronts \u2014 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>",
|