Self-hosted full-funnel visitor analytics for Vendure storefronts. Pageviews, time-on-page, exit pages, configurable funnel, UTM attribution, conversion goals with URL-glob matching, bot detection, and a per-visitor profile drawer with parsed user-agent and MaxMind geo. Privacy-first defaults: DNT, IP anonymisation, optional consent gate.
Since 0.8.0 the plugin also ships cart abandonment (detection,
signed recovery links, Slack notification, admin dashboard),
co-view product recommendations (also-viewed / personal /
trending), site search analytics (top queries, zero-result
queries, search-to-cart conversion) and journey-drawer buffs
(rage-click + dead-click hot-spot lists, per-session intent
labels).
Maintained by Wayne Garrison.
14-day free trial (card required, start it from the admin — nothing charged until day 15), then £9.95/month, or £199 one-off lifetime at elite.charity/licence/buy/vendure-plugin-visitor-analytics. Start the trial (or buy) from the plugin's admin page — checkout opens in a new tab and the key installs itself.
Add VisitorAnalyticsPlugin.uiExtensions to your compileUiExtensions
config to pick up the admin page (Marketing → Visitor Analytics in the
nav, since 0.19.0), the licence/evaluation banner and the dashboard widget
(Dashboard → Add widget → Visitor Analytics).
Since 0.18.2: events are pruned after 400 days by default (
retention); the visitor IP isreq.ipunless you list proxy headers intrustedIpHeaders;hulo.jscarries first-party visitor/session ids so cross-origin storefronts count uniques correctly.
The plugin ships a drop-in JS helper at /ees/hulo.js — one script
tag and every event API below is available on window.hulo. It handles
batching, sendBeacon on unload, auto rage-click + dead-click
detection, and an on-mount pageview. Bare minimum:
For a first-party integration (recommended — one bundle instead of a
second script tag), copy the equivalent typed helpers into your
storefront. The elite.charity Qwik storefront
uses this pattern. Every helper below is a thin wrapper around
POST /ees/track with a specific meta.eventType — the plugin's
server-side scanners look those event types up by name.
| Helper | When to call | What it feeds |
|---|---|---|
hulo.pageview() | first mount + every route change | pageview funnel, exit-page report |
hulo.productView(productId, variantId?) | on the PDP | co-view aggregation, also-viewed, trending, personal recs |
hulo.addToCart(variantId, qty, unitPriceMinor) | on the "add" button | search-to-cart conversion |
hulo.cartSnapshot({ currency, totalMinor, itemCount, items, email? }) | every cart change (add / remove / qty) | cart abandonment detection |
hulo.search(query, resultsCount) | on every executed search | top-queries, zero-result queries |
hulo.checkoutCompleted(orderCode, totalMinor) | on the thank-you page | closes any open abandoned_cart row for this session |
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 |
hulo.restoreCart(token) | on your /cart/restore?t=... route | rebuild a cart from a signed recovery link |
hulo.resumeCart(token) | same route, when you can resume the visitor's open order | as above, plus resumeOrderCode while the bound order is still open (0.18.0) |
hulo.recoveryConverted(orderCode) | on the thank-you page | attributes the order to the recovery link the visitor arrived through (0.18.0) |
Full payload shapes:
The recovery link the admin mints (see below) lands on
https://shop.example.com/cart/restore?t=<token>. Your storefront
needs a route that:
?t= from the URLGET /ees/recover-cart?t=<token> to fetch { items: [...] }
(or POST /ees/recover-cart/resume?t=<token> — see below){ variantId, qty } via your Vendure order API (usually
addItemToOrder(productVariantId, quantity)) — check the result is an
Order, not an ErrorResult (out of stock, purchase limit…)/cart route)hulo.recoveryConverted(order.code) (or
POST /ees/recover-cart/converted { t, orderCode }) so the cart is
attributed to the link — the helper remembers t in sessionStorage
from step 2, so this is a no-op for visitors who did not arrive through
a recovery linkResuming the visitor's order (0.18.0). When the host mints the link
with issueRecoveryLink(id, { resumeOrderCode }), both recovery endpoints
return orderCode, orderState and resumable, and the resume endpoint
adds resumeOrderCode — non-null only while that order is still in
AddingItems / ArrangingPayment. The Shop API cannot adopt an order
anonymously, so treat it as a hint: a signed-in owner already has it as
their active order (skip the re-add), a guest gets the items re-added as
usual. Either way the items array is always present as the fallback.
Guard against silently overwriting a live cart — if the visitor
already has items, show a "you already have items in your cart"
message and let them reconcile. See
elite.charity's src/routes/cart/restore/index.tsx
for a working reference implementation.
If you prefer to skip /ees/hulo.js, the raw POST shape is unchanged:
POST /ees/track accepts a batch of up to 50 events at once. The
customerId of a batch comes from the Vendure session of the request
(never from the body); OPTIONS /ees/track answers the CORS preflight.
With corsAllowedOrigins set, a request from any other origin gets no
Access-Control-Allow-Origin header.ees_vid, ees_sid) issued + refreshed
automatically. When signingSecret is set, cookies are HMAC-signed
and tampered values are rejected — the visitor gets a fresh id.Secure flag is set automatically when serving over HTTPS.Per event:
ua-parser-js → browser, version, OS,
device.geolite2-redist). Skipped when the upstream
proxy already provides a country (Cloudflare, Akamai, Fastly).utmSource, utmMedium, utmCampaign, utmTerm, utmContent. Plus
referrerDomain for grouping by source even when UTM is absent.isBot=true.A goal is a URL glob that, when matched, counts the visitor as having
converted. Supports * (within segment) and ** (across segments).
Stats at GET /ees/goals/stats?days=30&channelId=1.
honorDoNotTrack: true (default) — DNT: 1 and Sec-GPC: 1 requests
get a 200 with {stored:0, skipped:'dnt'}.anonymizeIp: true (default) — IPv4 last octet dropped before
storage; IPv6 reduced to the first 3 hextets. ipHash still uses the
raw IP so unique-visitor counts stay accurate.requireConsent: false (default) — flip on to require a consent: true
body field or an ees_consent=1 cookie before ingest.dropBotEvents: false (default) — flip on to skip bot UAs entirely.Detects sessions that got as far as putting items in the cart but
never fired checkout_completed. Turns them into AbandonedCart rows
you can send a recovery email against.
How detection works.
The plugin runs a worker-only sweep every 5 minutes. It looks at every
session that fired at least one cart_snapshot event, and:
checkout_completed landed later — do nothing (or if an
abandoned_cart row already exists, promote it to converted).cart_snapshot is older than abandonment.windowMinutes
(default 30) — open an abandoned_cart row, keyed on sessionId
(unique — you can't double-open the same session).Recovery link.
POST /ees/abandoned-carts/:id/recovery-link mints a signed opaque
token and returns { ok: true, url: '<storefront>/cart/restore?t=...' }.
The token is time-bounded (recoveryLinkTtlHours, default 72) and
non-reusable. The storefront exchanges it via
GET /ees/recover-cart?t=<token> to get back the persisted item list.
Multi-storefront installs: set abandonment.storefrontBaseUrls to a map of
channel code → storefront origin (for example
{ licensedock: 'https://license-dock.com' }) and each cart's link points
at the storefront it was abandoned on; channels not listed fall back to
storefrontBaseUrl.
Set abandonment.recoveryLinkSecret in plugin options to enable this —
without it, the endpoint returns { error: 'recovery-disabled-or-not-found' }.
Attribution (0.18.0).
Every abandoned_cart row carries recoveryStep — link_issued →
link_opened → resumed → converted, never moving backwards — plus
convertedAt, convertedOrderId, convertedOrderCode and
resumeOrderCode. Steps advance as the link is minted, exchanged
(recover-cart), resumed (recover-cart/resume) and finally reported
converted by the storefront (POST /ees/recover-cart/converted { t, orderCode } — token-bound, the order must exist and be past
AddingItems). The scanner's own checkout_completed match still marks
rows converted and stamps convertedAt, but leaves recoveryStep
alone — so "converted via link" is exactly recoveryStep = 'converted'.
GET /ees/abandoned-carts/summary returns an attribution block:
Email opt-out (0.18.0).
Recovery emails must carry an unsubscribe link. Build it with
abandonedCartService.buildOptOutLink(email) and add the headers from
abandonedCartService.buildListUnsubscribeHeaders(email)
(List-Unsubscribe + List-Unsubscribe-Post: List-Unsubscribe=One-Click)
so Gmail / Outlook / Yahoo show their native "Unsubscribe" button. Both
point at GET|POST /ees/abandoned-carts/opt-out?e=<token> on the Vendure
server: GET renders a small confirmation page, POST is the RFC 8058
one-click form. The token is base64url(email).hmac(email) signed with
abandonment.optOutSecret (default: recoveryLinkSecret, then
signingSecret) — nobody can unsubscribe someone else.
Before every send call await abandonedCartService.isOptedOut(email);
it fails closed (a DB error counts as opted out). Opt-outs live in
abandoned_cart_opt_out (keyed by the SHA-256 of the lower-cased
address). Admin: GET /ees/abandoned-carts/opt-outs,
POST /ees/abandoned-carts/opt-outs/remove { email } after an explicit
customer request; GET /ees/abandoned-carts/:id reports optedOut.
Schema. The 0.18.0 columns and the opt-out table are added at boot
(an information-schema check, then ALTER TABLE … ADD COLUMN /
CREATE TABLE IF NOT EXISTS — MariaDB, MySQL 8 and PostgreSQL). Installs
that run TypeORM migrations for plugins can generate one as usual — the
AbandonedCart entity declares the same columns, so the generator finds
nothing to add once the plugin has booted. Closed carts (expired /
converted / dismissed) older than 180 days are pruned monthly on the
worker; every raw statement in the plugin is checked against PostgreSQL
17 and MariaDB by tests/pg-corpus.test.ts.
Slack notification.
abandonment.slackWebhookUrl + abandonment.slackMinValueMinor
control an at-most-once Slack post per abandonment above the value
threshold. Useful for sales teams that follow up on high-value drops
manually.
Admin dashboard. Under Analytics → Abandoned carts. Filters by status / min value / email / window. Actions per row: mint recovery link (copies URL to clipboard), mark recovered manually, dismiss. CSV export.
A ProductCoView aggregate table holds a per-triple counter
(productIdA, productIdB, channelId) → viewsTogether. Rebuilt every 6
hours from the last 24h of product_view events, bounded to 20 events
per session so runaway bot sessions can't skew the table.
Denormalised — we store both (A, B) and (B, A) — so read-side
lookups are one indexed scan.
Three endpoints, all safe from the storefront (no PII):
| Endpoint | Use |
|---|---|
GET /ees/recommendations/also-viewed?productId=42&limit=10 | product-page rail: "customers who viewed X also viewed…" |
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 |
GET /ees/recommendations/trending?hours=24&limit=10 | homepage rail: most-viewed products in the window. Reflects real intent (not search-console clicks) |
GET /ees/recommendations/aggregate-now (SuperAdmin only) runs the
sweep now. It is idempotent: events up to the persisted end of the last
sweep are not counted again, so it is safe to call after the 6-hourly
cron. Add ?force=1 to rebuild regardless (e.g. after truncating
product_co_view). Pairs not refreshed in 90 days are pruned monthly.
Zero-schema-cost queries over the existing visitor_event table where
the storefront has fired hulo.search(query, resultsCount) events.
| Endpoint | Use |
|---|---|
GET /ees/search-analytics/top?days=7 | top queries by volume with average results count |
GET /ees/search-analytics/no-results?days=7 | queries that returned zero hits — direct catalogue-gap intel |
GET /ees/search-analytics/conversion?days=7 | of sessions that searched, what fraction went on to add_to_cart |
| Endpoint | Use |
|---|---|
GET /ees/journey/rage-clicks?days=7 | rage-click hot-spot list per URL. Pages where visitors are frustrated |
GET /ees/journey/dead-clicks?days=7 | dead-click hot-spot list per URL. Elements that LOOK clickable but aren't |
GET /ees/journey/session-summary?visitorId=abc | per-session summary with a heuristic intent label (purchase / abandon / frustrate / consider / browse / bounce) |
Rage-click auto-detector fires on ≥3 pointerdown events within 500ms and a 20-pixel radius. Dead-click auto-detector fires when a click lands on a non-interactive element and no navigation / significant scroll follows within 400ms. Both are conservative heuristics — the signal is direction-of-frustration, not a metric to optimise against.
SSE stream at GET /ees/visitors/live pushes the active-visitor count
and the 20 most recent URLs every 5 seconds. Auto-reconnects.
Click any visitor for the full timeline: pages, custom events, time-on-page, country, browser, OS.
GET /ees/visitors/export.csv?days=N (max 90 days) returns the raw
events with full enrichment, streamed in id order (up to 200 000 rows);
GET /ees/abandoned-carts/export.csv?days=N streams up to 50 000 carts.
Weekly report. Every Monday at 08:00 (server-local time) the worker emails a short "what Visitor Analytics did for you" report for the previous seven days: unique visitors, sessions, page views, abandoned carts detected, carts recovered and their value, conversion goals completed — each with the change against the week before — plus up to three next steps taken from the setup checklist, and the licence notice when a trial or key is about to end. Every figure comes from the same tables the admin page reads.
GET|POST /ees/digest/settings ({ enabled?, recipient? }),
POST /ees/digest/send-test ({ to? }) and GET /ees/digest/preview.POST /ees/digest/settings
{ "enabled": false }). Nothing is sent when no recipient can be
resolved.SMTP_SERVER (or SMTP_HOST), SMTP_PORT, SMTP_USER, SMTP_PASSWORD
and SMTP_FROM environment variables through nodemailer, which is an
optional peer dependency — install it (yarn add nodemailer) to enable
the report. Without SMTP the card still works but Send me a test
reports why nothing went out; the rest of the plugin is unaffected.hulo_plugin_prefs row, so several worker replicas send one
report, not one each.Setup checklist. The top of the admin page lists what still needs
doing, from real checks: tracking events arriving in the last 7 days,
publicBaseUrl pointing at a public host, a storefront URL for recovery
links, at least one conversion goal, and Slack alerts or recovery links
enabled. It disappears once everything is done, or when you hide it — and
comes back if something it had ticked stops being true. GET /ees/setup
returns the same items.
Dashboard widget. Dashboard → Add widget → Visitor Analytics shows
unique visitors, page views and carts recovered for the last 7 days, the
licence notice when there is one, and a link to the full page
(GET /ees/widget).
Usage counters. The same 30-day numbers (no personal data) travel with the daily heartbeat and the evaluation ping so renewal and win-back emails from HULO can say what the plugin did for the store.
Public (no auth — CORS-permissive for browser calls from any storefront origin):
| Method | Path | Purpose |
|---|---|---|
POST | /ees/track | ingest a batch of visitor events |
GET | /ees/hulo.js | typed storefront helper JS (since 0.8.1) |
GET | /ees/recover-cart?t=<token> | resolve a recovery token → items (+ orderCode, orderState, resumable since 0.18.0); 30/min per IP |
POST | /ees/recover-cart/resume?t=<token> | as above plus resumeOrderCode while the bound order is open (0.18.0); 30/min per IP |
POST | /ees/recover-cart/converted | { t, orderCode } — attribute a placed order to its recovery link (0.18.0); 10/min per IP |
GET/POST | /ees/abandoned-carts/opt-out?e=<token> | email opt-out; POST is RFC 8058 one-click (0.18.0); 10/min per IP |
GET | /ees/recommendations/also-viewed?productId=… | co-view recs |
GET | /ees/recommendations/personal?visitorId=… | personalised recs |
GET | /ees/recommendations/trending?hours=… | most-viewed products |
Admin (Vendure ReadCustomer unless noted; requires a
Vendure admin session cookie):
| Method | Path | Purpose |
|---|---|---|
GET | /ees/visitors/summary | top-line + daily series |
GET | /ees/visitors/sources | top sources by visits |
GET | /ees/visitors/top-pages | most-visited URLs |
GET | /ees/visitors/funnel | configurable funnel |
GET | /ees/visitors/exit-pages | top exit pages |
GET | /ees/visitors/top-events | top custom events |
GET | /ees/visitors/live | SSE live-now stream |
GET | /ees/visitors/journey/:visitorId | per-visitor timeline |
GET | /ees/visitors/recent | recent events |
GET | /ees/visitors/export.csv | CSV export |
GET | /ees/goals | list conversion goals |
POST | /ees/goals | create a goal |
PUT | /ees/goals/:id | update a goal |
DELETE | /ees/goals/:id | delete a goal |
GET | /ees/goals/stats | per-goal completion stats |
GET | /ees/visitors/status | version + update status |
GET | /ees/licence/status | licence/evaluation state + lifecycle notice, premium list, 30-day figures, free-tier cap (0.19.0) |
POST | /ees/eval/remind-me | { email } — email the admin before the evaluation ends (0.19.0, UpdateSettings) |
GET | /ees/setup | setup checklist items (0.19.0) |
GET | /ees/widget | dashboard widget payload, last 7 days (0.19.0) |
GET | /ees/digest/settings | weekly report settings (0.19.0) |
POST | /ees/digest/settings | { enabled?, recipient? } (0.19.0, UpdateSettings) |
POST | /ees/digest/send-test | { to? } — send the week-to-date report now (0.19.0, UpdateSettings) |
GET | /ees/digest/preview | this week's report as HTML (0.19.0) |
GET | /ees/abandoned-carts | paginated list w/ filters (0.8.0) |
GET | /ees/abandoned-carts/summary | totals + recovery rate (0.8.0) |
GET | /ees/abandoned-carts/:id | detail incl. parsed items (0.8.0) |
POST | /ees/abandoned-carts/:id/recovery-link | mint signed URL (0.8.0, UpdateCustomer); body { resumeOrderCode? } binds it to an order (0.18.0) |
GET | /ees/abandoned-carts/opt-outs | opted-out addresses (0.18.0) |
POST | /ees/abandoned-carts/opt-outs/remove | { email } — re-enable after an explicit request (0.18.0, UpdateCustomer) |
POST | /ees/abandoned-carts/:id/status | mark recovered/dismissed (0.8.0, UpdateCustomer) |
GET | /ees/abandoned-carts/export.csv | CSV export (0.8.0) |
GET | /ees/recommendations/aggregate-now | force co-view sweep (0.8.0, SuperAdmin) |
GET | /ees/search-analytics/top | top queries (0.8.0) |
GET | /ees/search-analytics/no-results | zero-result queries (0.8.0) |
GET | /ees/search-analytics/conversion | search→cart rate (0.8.0) |
GET | /ees/journey/rage-clicks | rage-click hot spots (0.8.0) |
GET | /ees/journey/dead-clicks | dead-click hot spots (0.8.0) |
GET | /ees/journey/session-summary?visitorId=… | per-session intent labels (0.8.0) |
User manual + screenshots: huloglobal.com/vendure-plugins/visitor-analytics/docs/
Re-send every active key on file at elite.charity/licence/forgot.
Commercial. Buy at elite.charity/licence/buy/vendure-plugin-visitor-analytics.
All notable changes to @huloglobal/vendure-plugin-visitor-analytics are
documented here. The format follows
Keep a Changelog and this project
adheres to semantic versioning.
Retention pass: a weekly report, a setup checklist, a dashboard widget and
a licence banner that says what the plugin has done. No schema changes to
the plugin's own tables; one shared hulo_plugin_prefs table (created on
first boot by the licence SDK) holds the report settings.
GET|POST /ees/digest/settings,
POST /ees/digest/send-test, GET /ees/digest/preview. Sent through the
host's SMTP_* environment via nodemailer, now an optional peer
dependency. Default recipient: the licence holder, else the trial lead.GET /ees/setup):
tracking events arriving in the last 7 days, public base URL, storefront
URL for recovery links, a conversion goal, Slack alerts or recovery links.
Hidden once complete or dismissed; shown again if a ticked item regresses.
The undone items are the report's "next steps".GET /ees/widget).VisitorsNavModule, included in uiExtensions).POST /ees/eval/remind-me,
which was missing); "Renews automatically — current key valid until …"
on subscriptions.GET /ees/abandoned-carts/summary.GET /ees/licence/status reports capReached
(today's 100-event budget spent) and the page shows an amber notice; the
Live now tile shows an unlock chip on the free tier instead of a silent
"reconnecting".visitorsTracked, sessions, pageViews,
cartsAbandoned, cartsRecovered, recoveredValuePence,
goalsCompleted, last 30 days, numbers only) now travel with both the
evaluation ping and the daily heartbeat, so licensed installs keep
reporting after purchase.@huloglobal/vendure-licence-sdk ^0.15.1.jti: unlicensed forever once a key was
activated from the admin page or installed by the buy-from-admin claim,
so the licence server dropped those installs' usage figures. It now
reads the key currently in force on every ping.HAVING rewrite made
trending, topQueries and zeroResultQueries fail on MySQL/MariaDB
("Unknown column 'meta' in 'HAVING'": MySQL resolves only the output
alias there, Postgres only the expression). The filters now sit in
WHERE as the expression, which both accept. 0.18.4 is deprecated on
npm.PREPARE on MySQL/MariaDB (HULO_MYSQL_URL), so a fix for one dialect
can no longer break the other unnoticed.Optimisation and Postgres pass — no new features, no new tables, no new indexes (nothing to migrate).
visitor_event, conversion_goal, abandoned_cart, product_co_view)
and on the Vendure tables the plugin reads (customer, order,
order_channels_channel, product, product_variant,
product_translation, product_variant_translation) is quoted;
NOW() - INTERVAL … became DATE_SUB(…); aliases are no longer used in
HAVING; the personal-recommendations seed query no longer combines
DISTINCT with an ORDER BY on an unselected column; the
CAST(… AS UNSIGNED) on product ids / result counts is guarded by a
digits check (Postgres raises where MySQL returns 0); the co-view upsert
quotes its ON CONFLICT target; SUM(boolean) in the session summary
became MAX(CASE …); boolean columns are compared with TRUE/FALSE;
the abandoned-cart controller's queries go through the dialect adapter;
and the boot-time abandoned_cart ALTERs quote the column name (an
install without the migration got a lowercase duplicate) behind an
information-schema check (MySQL 8 has no ADD COLUMN IF NOT EXISTS).
A corpus test (tests/pg-corpus.test.ts, skipped unless HULO_PG_URL
is set) runs all 98 statements against PostgreSQL 17 with TypeORM-quoted
stand-ins for the entity and Vendure tables.POST /ees/track and customerId. The customer is resolved from
the Vendure session (activeUserId → customer.userId, cached 10 min);
the body value is ignored, so an anonymous beacon can no longer file
events under an arbitrary customer id. A storefront that never sent the
session (the bundled tracker does not) sees no change: those events
were already unlinked.OPTIONS /ees/track answers 204 with the same headers as
the POST (a JSON fetch from another origin sends a preflight). A
disallowed origin gets no Access-Control-Allow-Origin header at all —
the previous null is itself a valid origin value.aggregate-now after the 6-hourly sweep — count
nothing twice; aggregate-now?force=1 rebuilds regardless.: keepalive comment every 15 s.abandoned_cart rows in
status expired / converted / dismissed older than 180 days and
product_co_view pairs not refreshed in 90 days are deleted in batches
of 5 000.normaliseUrl and boundedMeta moved to src/ingest-utils.ts (decorator-free, unit-tested). No behaviour change.Reliability and security pass — no new features. Two indexes on
visitor_event (createdAt, (channelId, createdAt)) are declared (run
your migrations).
hulo.js now keeps a
first-party visitor id (localStorage) and a 30-minute session id and
sends them with every batch; on a storefront hosted on a different origin
than the API the cookies never travelled, so uniques equalled page views.hulo.js sends location.href; events are now stored as
path + query, so funnels, conversion goals and source reports match
again.SUM(boolean) replaced with
SUM(CASE …) everywhere (Postgres).% no longer turns every beacon into a 500;
UTM fields are capped to their column widths (a long utm_source used
to drop the whole batch); oversized meta (large cart snapshots) is
shrunk item-wise instead of cut mid-JSON, so abandoned carts are still
detected.trustedIpHeaders.trending is capped at 7 days and memoised for a minute; CSV
exports quote cells that would run as spreadsheet formulas; the journey
endpoint returns only the fields the drawer shows; goal stats count
completions correctly (COUNT(v.id)); goal ids and query clamps are
validated.signingSecret before the public
constant.@, the restart poll is cancelled on destroy.retention defaults to { days: 400 } (false keeps everything) and
the sweeper runs on the worker only.insert() (no reload round trip).Same content as 0.18.0, whose publish was left staged on the npm registry and never promoted; 0.18.1 is the first installable release of this line.
issueRecoveryLink(cartId, { resumeOrderCode }) (and POST /ees/abandoned-carts/:id/recovery-link with the same body) binds the link to the visitor's open Vendure order. GET /ees/recover-cart now returns orderCode, orderState and resumable, and the new public POST /ees/recover-cart/resume?t=… adds resumeOrderCode — set only while that order is still AddingItems / ArrangingPayment. Storefronts keep re-adding items as the universal fallback.abandoned_cart columns recoveryStep (link_issued → link_opened → resumed → converted, monotonic), convertedAt, convertedOrderId, convertedOrderCode and resumeOrderCode. The storefront reports a recovered checkout with the token-bound public POST /ees/recover-cart/converted { t, orderCode }; the scanner's own checkout_completed match sets convertedAt too. GET /ees/abandoned-carts/summary gains an attribution block (link issued / opened / resumed / converted counts, value recovered via link, opt-outs) and the list endpoint returns the new columns.abandoned_cart_opt_out table. GET|POST /ees/abandoned-carts/opt-out?e=<token> (public; POST is the RFC 8058 one-click form) records an opt-out for the address in the HMAC token. Service API: isOptedOut(email) (fails closed), optOut, optIn, listOptOuts, buildOptOutLink(email) and buildListUnsubscribeHeaders(email) for the List-Unsubscribe / List-Unsubscribe-Post headers. Admin: GET /ees/abandoned-carts/opt-outs, POST /ees/abandoned-carts/opt-outs/remove { email }; the detail endpoint reports optedOut. New option abandonment.optOutSecret (defaults to recoveryLinkSecret, then signingSecret).hulo.resumeCart(token) and hulo.recoveryConverted(orderCode) — the helper remembers the token from restoreCart / resumeCart in sessionStorage, so the thank-you page needs one call.isResumableOrderState, advanceRecoveryStep, normaliseEmail, hashEmail, buildOptOutToken, verifyOptOutToken, buildOptOutUrl, buildListUnsubscribeHeaders.recover-cart and resume 30/min, converted and opt-out 10/min) and answer with Cache-Control: no-store.ADD COLUMN IF NOT EXISTS / CREATE TABLE IF NOT EXISTS (MariaDB and PostgreSQL), so installs that do not run TypeORM migrations for plugins need no manual step. Installs that do can generate a migration as usual — the entity declares the same columns.abandonment.storefrontBaseUrls (channel code → storefront origin) makes a cart abandoned on a second storefront link back to that storefront; unlisted channels use storefrontBaseUrl as before./cart route, and must treat an ErrorResult from addItemToOrder as a failed line. The reference storefront implementation now does both.hulo-global-logo.svg is now the HG wordmark on the HULO black rounded square (the same mark as the huloglobal.com header), with explicit -light and -dark variants alongside the auto-switching default..env edit, no restart. Renewed subscription keys are picked up automatically too. New admin endpoints licence/purchase-link and licence/claim-status.@huloglobal/vendure-licence-sdk ^0.12.0.dbConnectionOptions use.
Verified against PostgreSQL 17. MySQL/MariaDB installs are unaffected
(byte-identical passthrough).anonymizeIp produced a malformed address (e.g. fe80::1::) for
already-abbreviated IPv6 inputs. Abbreviated addresses are now left
unchanged; full-form IPv6 still truncates to the first three hextets.GET /ees/abandoned-carts now includes each cart's parsed
items array plus a short server-rendered itemsPreview
string ("Windows 10 Pro, Office 2021 Pro +2 more"), sorted by
quantity descending. Admin can see WHAT was abandoned without
clicking through to the detail endpoint.GET /ees/abandoned-carts/:id fills in missing name (and
productId when it can) on every item by looking up
product_variant_translation / product_translation. Only
fills where the storefront snapshot didn't already capture a
name — a stored name at cart time is more accurate than a live
catalog lookup (products can be renamed after abandonment).itemsJson string from its
response — clients that need the JSON blob can hit the detail
endpoint. Response payload stays lean.trending, also-viewed, personal)
now enrich every row with productName and productSlug via a
single bulk lookup against product_translation. English
translation preferred for multi-locale stores; single-locale
installs land on their only translation. Soft-deleted products
are excluded.RecommendedProduct.{ productId, productName, productSlug, score, views } — the
addition is backwards-compatible for consumers that only read
productId, but callers that render the row can now show the
name without a second round-trip. Admin UI updated in the
parallel ee.software commit to render "Windows Server 2022
Datacenter #42" instead of just "#42"./ees/hulo.js storefront helper, cart-abandonment configuration
and lifecycle, recommendation endpoints, search analytics,
journey drawer buffs. Includes a walkthrough of the
storefront-side /cart/restore route customers implement to
consume recovery links./ees/hulo.js — the plugin now serves a drop-in typed storefront
helper at this path. One <script src> tag and every event API
(hulo.cartSnapshot, hulo.productView, hulo.search,
hulo.checkoutCompleted, hulo.restoreCart) is available on
window.hulo. Handles batching, sendBeacon on unload, and
installs auto rage-click + dead-click detectors. Served with a
10-minute browser TTL + 24-hour stale-while-revalidate and
permissive CORS so it works cross-origin from any storefront.Cart abandonment — end-to-end.
AbandonedCart entity, keyed on session id. One row per
abandoned session, refreshed in place until it either converts
(order placed) or expires (recovery window elapses).AbandonedCartService.scan() — periodic sweep finds sessions with
cart_snapshot events but no checkout_completed in the
abandonment window (default 30 min). Auto-promotes previously
abandoned rows to converted when the customer later checks out.POST /ees/abandoned-carts/:id/recovery-link
returns a time-bounded opaque token the storefront exchanges via
GET /ees/recover-cart?t=… to rebuild the exact cart. Storefront
never sees the underlying items until the token is presented.abandonment plugin option.GET /ees/abandoned-carts — paginated list with status,
value and email filters.GET /ees/abandoned-carts/summary — totals, recovery rate,
recovered vs. lost value in the window.GET /ees/abandoned-carts/:id — detail incl. parsed items.POST /ees/abandoned-carts/:id/status — mark recovered /
dismissed / re-open.GET /ees/abandoned-carts/export.csv — CSV export.Co-view product recommendations.
ProductCoView aggregate table. Scanner walks recent
product_view events per session, extracts every ordered pair,
and increments a per-triple counter. Bounded to 20 events per
session so runaway bot sessions can't skew the table.(A, B) and (B, A) stored — so read-side
lookups are one indexed scan.GET /ees/recommendations/aggregate-now for admins to kick a
fresh run after a data backfill.GET /ees/recommendations/also-viewed?productId=… — the
"customers who viewed X also viewed…" rail on a product page.GET /ees/recommendations/personal?visitorId=… — personalised
recs for a returning visitor, from their last 10 product views
over 30 days. Excludes seeds.GET /ees/recommendations/trending?hours=24 — most-viewed
products in the window. Reflects real interest, not search-
console clicks.Site search analytics.
visitor_event where the storefront has fired
hulo.search(query, resultsCount). Zero new schema.GET /ees/search-analytics/top — top queries by volume with
average results count.GET /ees/search-analytics/no-results — top zero-result queries.
Direct catalogue-gap intel.GET /ees/search-analytics/conversion — of sessions that
searched, what fraction went on to fire add_to_cart.Journey drawer buffs.
intent label
(purchase / abandon / frustrate / consider / browse /
bounce) computed from event history — one glance per session
in the Journey drawer instead of scrolling event rows.Storefront helper events (documented in the README).
hulo.cartSnapshot({ currency, totalMinor, itemCount, items, email })hulo.productView(productId, productVariantId?)hulo.search(query, resultsCount)hulo.rageClick(url, selector?) / hulo.deadClick(url, selector)hulo.checkoutCompleted()POST /ees/track endpoint with a
standard shape, so admins can also fire them from any language.checkout_completed is now a first-class recognised event type
— the abandonment scanner uses it to auto-close matched rows.warnIfIncompatibleVendure(). Logs a non-fatal warning when the runtime
@vendure/core version is outside the tested range. Silent when inside;
fail-open on unparseable versions.@vendure/core tightened to >=3.5.0 <4.0.0 — Vendure 3.5,
3.6 and 3.7 are all covered. Anything under 3.5 has never been tested;
anything from 4.0 upwards is deferred until the changelog is reviewed.@huloglobal/vendure-licence-sdk@^0.6.0.huloVisitorSummary, huloVisitorSources,
huloVisitorTopPages, huloVisitorFunnel, huloVisitorJourney.POST /ees/track) stays REST — it's anonymous,
high-frequency, and can ingest millions of events a day; the
resolver stack would add pointless overhead per event.isLicensed()
helper. Unlicensed installs get:
POST /ees/track (returns
{skipped: 'free-tier-cap'} after that);display: block on mobile tables that broke
the row / cell alignment.signValue /
verifySignedValue helpers — tampered cookies are rejected.Secure cookie flag is set automatically when serving over HTTPS.POST /ees/track.corsAllowedOrigins option restricts CORS reflection to the
configured list (legacy wildcard preserved when empty).options.retention.@huloglobal/vendure-licence-sdk@^0.2.0.UpdateChecker integration — /ees/visitors/status endpoint returns
version + update info; admin banner appears on new releases.ConversionGoal entity with a URL-glob
matcher (* within segment, ** across segments). Pageviews matching
a goal are tagged with goalId at ingest. CRUD endpoints
(GET /ees/goals, POST /ees/goals, PUT /ees/goals/:id,
DELETE /ees/goals/:id) and GET /ees/goals/stats for completion
totals per period.isBot boolean on every event.
Default keeps bot events for visibility; new dropBotEvents option
skips ingest entirely.honorDoNotTrack (default true) — DNT: 1 returns
{stored:0, skipped:'dnt'}anonymizeIp (default true) — IPv4 last octet / IPv6 last 80 bits
dropped before storage; ipHash still uses the raw IPrequireConsent (default false) — gate ingest behind a body
consent:true or cookie ees_consent=1GET /ees/visitors/export.csv?days=N (max 90).utmSource / utmMedium / utmCampaign /
utmTerm / utmContent and referrerDomain columns parsed from every
incoming pageview URL. New GET /ees/visitors/sources admin endpoint
groups visitors by (source, medium) plus per-source conversion
counts (reached product page, reached cart/checkout).GET /ees/visitors/live pushing the active-visitor count and the
20 most recent URLs every 5 seconds. SSE clients auto-reconnect.GET /ees/visitors/top-events,
paginated.VisitorAnalyticsPlugin — ingest endpoint + admin dashboards.VisitorEvent entity capturing pageview / unload / event rows with
full UA parse, MaxMind geo enrichment, raw + hashed IP.@huloglobal/vendure-licence-sdk with
revocation polling.