@thg-agentic/hyper-personalisation-web 3.22.0 → 3.23.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @thg-agentic/hyper-personalisation-web
2
2
 
3
3
  The browser SDK for THG Hyper-Personalisation, a customer data platform. It implements the event
4
- specification (spec 1.15, stamped on every event as `context.spec.version`) for any storefront: it
4
+ specification (spec 1.18, stamped on every event as `context.spec.version`) for any storefront: it
5
5
  sends events in the Segment wire format, applies consent strictly whichever CMP the site uses, reads
6
6
  the GA4 `dataLayer` the site already pushes to, and has no runtime dependencies.
7
7
 
@@ -53,6 +53,7 @@ collects, is a settings change and never a deploy:
53
53
  | `apiHost` | Where events go when the page set no `apiHost` (an `https://` host only). A page's own `apiHost` is a proxy and carries events too (since 3.20). |
54
54
  | `plan.track` | Segment's tracking plan: an event with `enabled: false` is dropped at the source. The console's blocked events arrive here. |
55
55
  | `sampleRate` | The share of visitors events are sent for, by a hash of the anonymous id (since 3.18). Absent: 1. |
56
+ | `onsite` | The live onsite forms (`{ forms, capture }`): triggers, pages, caps, consent texts (an SMS one for a form that collects texts), prize labels. Never creatives or odds. Used only by a page that passes `onsite`; see [Onsite forms](#onsite-forms). |
56
57
  | `debug` | `{ enabled, tokenSha256 }`: console logging for everyone, or for a session opened with `?hp_debug=<token>` whose SHA-256 is `tokenSha256` (since 3.18). `?hp_debug=` ends it. |
57
58
 
58
59
  **The page configures only what the page knows** (the second argument):
@@ -73,6 +74,7 @@ collects, is a settings change and never a deploy:
73
74
  | `debug` | Log every event and delivery problem. Also on when the settings' `debug` says so. |
74
75
  | `settings` | Development and tests only: use this settings response, never fetch or cache one. |
75
76
  | `extend` | `createAnalytics` options that are functions, such as `getRecommendations` or `getExtensions`. |
77
+ | `onsite` | `true`, or the onsite options: start [onsite forms](#onsite-forms) when the settings have a live one. Absent: the runtime is never loaded. |
76
78
 
77
79
  What `bootstrap` does:
78
80
 
@@ -85,7 +87,10 @@ What `bootstrap` does:
85
87
  and an `ETag`, so the browser revalidates for a `304`). A key the API switches off clears the cache, so the next page is
86
88
  off too.
87
89
  - **Consent**, identity, one page view, the dataLayer bridge and a once-per-session `identify`, as
88
- configured.
90
+ configured. A page restored from the back/forward cache runs no script again, so `bootstrap`
91
+ reads consent again, then sends its `back_forward` page view on `pageshow` and reports its lists
92
+ afresh (3.22.2). A host on `createAnalytics` sends that view itself: `page({ navigation_trigger:
93
+ 'back_forward' })` on a `pageshow` whose `persisted` is true.
89
94
  - **Never waited for.** Call `hp` (`@thg-agentic/hyper-personalisation-web/hp`) from anywhere, at
90
95
  any time, and never await or flush it: calls made before the client starts wait, each with the
91
96
  messageId and time it was made at, and are sent in order once it has. The page view is stamped
@@ -204,7 +209,7 @@ Since 3.19 (spec 1.12), a search box's suggestions are their own event:
204
209
  | `storageNames` | `thg_hp_*` | Rename what the SDK stores; see [Storage](#storage). |
205
210
  | `plan` | none | A Segment tracking plan, `{ track: { [event]: { enabled } } }`; `__default` covers unlisted events. |
206
211
  | `sampleRate` | `1` | The share of visitors to send for, by a hash of the anonymous id salted `hp_sample`; a visitor with no anonymous id is sent for. A consent withdrawal is always sent. Since 3.18. |
207
- | `queryParamAllowlist` | none | Keep only these query params (plus campaign params) in `context.page`. Patterns are anchored and case-insensitive. |
212
+ | `queryParamAllowlist` | none | Keep only these query params (plus campaign params) in `context.page`, and in a clicked link's `href`, which leaves another site's link as origin and path only (3.22.2). Patterns are anchored and case-insensitive. |
208
213
  | `queryParamBlocklist` | `[]` | Strip these params. Ignored when an allowlist is given. |
209
214
  | `sensitiveParamAllowlist` | `[]` | Whatever else is configured, a param is always dropped from every reported URL when its name contains `email`, `token`, `password`, `secret`, `auth`, `session`, `phone`, `address`, `card`, `customerid` and the like (see `SENSITIVE_PARAM_FRAGMENTS` in `src/context.js`), or when its value is an email address or a card-length number — campaign params and the referrer included. This exempts names from the first rule, for a false positive such as an `author` filter; nothing exempts a value. |
210
215
  | `getAnonymousId`, `getUserId`, `getSessionId` | none | Identity accessors. Without `getSessionId` the SDK keeps its own session (see [Session ids](#session-ids)); a missing or empty device id gives way to the SDK's own (see [Device ids](#device-ids)). |
@@ -329,6 +334,11 @@ render(body);
329
334
  hp.recordExposure(body);
330
335
  ```
331
336
 
337
+ For a decisions response the exposure lists each slot's `decision_id`, which is what marks that
338
+ decision shown (a click is a `Slot Engaged` with `engagement: 'click'` and the same `decision_id`).
339
+ It is one event per response, so record it once the slots are rendered: a slot rendered below the
340
+ fold counts as shown.
341
+
332
342
  Since 3.7, recording a decisions response also puts its decisions in force. Every later event in
333
343
  the browser session carries them as `context.decisions`, latest per slot, so a basket is credited
334
344
  to the slot shown before it. Send the session id your events carry as `session_id` on
@@ -338,13 +348,7 @@ to the slot shown before it. Send the session id your events carry as `session_i
338
348
  ### Server-side assignments
339
349
 
340
350
  A page your server rendered from `POST /v1/assignments` and cached per arm has no `decision_id` in
341
- it. Since 3.10, `hp.assignmentDecisionId({ slot, arm })` derives the one HP logged, synchronously,
342
- for that page's `Slot Engaged`:
343
-
344
- ```js
345
- const decisionId = hp.assignmentDecisionId({ slot, arm });
346
- if (decisionId) hp.slotEngaged({ slot, arm, engagement: 'view', decision_id: decisionId });
347
- ```
351
+ it. Since 3.10, `hp.assignmentDecisionId({ slot, arm })` derives the one HP logged, synchronously.
348
352
 
349
353
  It reads the session cookie named in `identityCookies.sessionId`, else the visitor cookie
350
354
  (`identityCookies.anonymousId`), as your renderer forwards them to HP, so it answers once
@@ -353,20 +357,28 @@ with neither cookie. The id is `UUIDv5(ASSIGNMENT_DECISION_NAMESPACE, "<session>
353
357
  `assignmentDecisionId({ sessionId, slot, arm })` from the main entry computes it from values you
354
358
  pass, and the spec's `assignment/decision-id.json` has the vectors.
355
359
 
356
- Such a page also reports the property holdout's exposure itself, when the slot is on screen, for
357
- both groups. The answer's `hash` is `holdout` for a held-out visitor (cache that page apart from
358
- the `""` one); on any other page with a hash the visitor was treated. The exposure token is the
359
- assignment's derived id for the arm shown, control included. Since 3.13 the shares come from the
360
- property's settings when the exposure has none:
360
+ Such a page reports the property holdout's exposure itself, when the slot is on screen, for both
361
+ groups, and a `Slot Engaged` click when a link in the slot is clicked. The answer's `hash` is
362
+ `holdout` for a held-out visitor (cache that page apart from the `""` one); on any other page with
363
+ a hash the visitor was treated. The exposure token is the assignment's derived id for the arm
364
+ shown, control included, and a held-out visitor's exposure names no decision. Since 3.13 the shares
365
+ come from the property's settings when the exposure has none:
361
366
 
362
367
  ```js
363
368
  const token = hp.assignmentDecisionId({ slot, arm });
369
+ const holdout = hash === 'holdout';
370
+ // When the slot is on screen:
364
371
  hp.recordExposure({
365
372
  exposure: { token, surface: 'decisions', holdout },
366
373
  decisions: [holdout ? { slot, holdout: true } : { slot, arm, decision_id: token }],
367
374
  });
375
+ // When a link in the slot is clicked:
376
+ if (token && !holdout) hp.slotEngaged({ slot, arm, engagement: 'click', decision_id: token });
368
377
  ```
369
378
 
379
+ The exposure is what marks the decision shown; the click is its reward. Slot Engaged's `view` is
380
+ deprecated since spec 1.16: it repeated the exposure.
381
+
370
382
  A surface your server fetched from HP for the page, so the page never sees HP's answer, reports
371
383
  its exposure with `surfaceViewed` when it comes into view: a product rail your server asked HP for,
372
384
  which HP answers with its rail for a treated visitor and the site's own rail for a held-out one.
@@ -473,6 +485,145 @@ const badges = await proof.forProducts(['123', '456']); // GET /v1/social-proof,
473
485
  It sends the key and `hp-consent` itself; `propertyId` is needed only for a key scoped to several
474
486
  properties.
475
487
 
488
+ ## Onsite forms
489
+
490
+ Sign-up popups, sheets, bars, teasers, inline forms and spin-to-win
491
+ ([ADR 0064](../../docs/architecture/decisions/0064-onsite-capture.md)). Built dark: nothing shows
492
+ until a form is set live in the console. One line on the host:
493
+
494
+ ```js
495
+ const hp = await bootstrap({ writeKey: 'hp_pk_live_…' }, { consent: { provider: 'onetrust' }, onsite: true });
496
+ ```
497
+
498
+ With `onsite` set and a live form in the settings' `hp.onsite`, bootstrap loads `./onsite` as a
499
+ separate chunk and starts it; otherwise it is never fetched. A page on `createAnalytics` starts it
500
+ itself: `import { startOnsite } from '@thg-agentic/hyper-personalisation-web/onsite'` then
501
+ `startOnsite(client, options)`. On the CDN, `hp.min.js` loads `onsite.min.js` (and that loads
502
+ `onsite-render.min.js` once a trigger fires, `onsite-wheel.min.js` for a wheel,
503
+ `onsite-answers.min.js` for a date or number question) from beside itself; a self-hosted copy keeps
504
+ all four beside `hp.min.js`. The runtime watches triggers, caps and consent and asks for the
505
+ decision; the renderer and its stylesheet are fetched only when a trigger fires, alongside the
506
+ decision request, so a page whose forms never fire never downloads them. If the renderer cannot
507
+ load, the form is not shown (`hp.onsite.debug()` says `the renderer did not load`) and the page goes
508
+ on. A bundler splits `./onsite`, the renderer, the wheel and the answers the same way.
509
+
510
+ **Layout stability.** A form moving to a shorter step (the thanks after a slow sign-up) keeps the
511
+ card's size, its content centred, so nothing on screen moves under the shopper; a taller step still
512
+ grows. An image with `widths` (the smaller copies the console's upload made, at `-<w>w.webp`) is a
513
+ `srcset` with the original at its `width`, and `sizes` for where it shows: the whole screen under
514
+ 640px, else the card's width (46% of it beside a split).
515
+
516
+
517
+ **Questions.** A choice (`one`, `many`) shows as buttons, a list of radio buttons or checkboxes, or
518
+ a dropdown, as its question block's `display` says (a multiple choice is never a dropdown). A
519
+ `date` question is a birthday's day and month selects and, when asked, a year field, in the order
520
+ and month names of the form's `locale` (the page's `lang` when the form has none); or the browser's
521
+ own date field for any other date. A `number` question is a text field with a numeric keyboard and
522
+ its unit. A half-filled date, or a number out of the question's bounds or the gateway's limits (ten
523
+ digits, at most a billion either way), is the shopper's to correct before the step moves on; the
524
+ gateway checks every answer again. Answers are stored as option values, `YYYY-MM-DD` or `--MM-DD`,
525
+ and digits. A form that asks a date or a number loads their fields once, as it mounts; if they
526
+ cannot load, the question says it is not available and the form goes on without that answer (every
527
+ question is optional). A phone field's country code starts on the block's `country`, else the
528
+ region of the form's `locale`.
529
+
530
+ **How a form shows.** The browser judges the trigger; the server decides the rest. Each live form's
531
+ trigger is watched: `entry` (`delay_ms`), `exit`, `scroll` (`depth`), `time` and `idle`
532
+ (`seconds`), `inline` (rendered into `selector`) and `manual` (`hp.onsite.open(slot)`). Exit intent
533
+ on desktop arms after `delay_ms` (5 s) and once the pointer has been below the top tenth of the
534
+ viewport, and fires when it leaves through the top edge, moving up. On mobile it is a fast upward
535
+ flick (120px at 0.5 px/ms) after 30% of the page, or a return to the tab after 10 s away. Pages
536
+ (include/exclude globs on the path, `*` matching anything, in any case), devices (`mobile` is a coarse pointer
537
+ or under 768px), dates and caps are checked first; then `POST /v1/decisions` is asked for that one
538
+ slot. A held-out visitor, or no decision, sees nothing. One overlay per page view: of the forms that
539
+ fire within 50 ms, the highest `priority`. With the property's `overlay_gap_minutes`, no popup, sheet or full-screen form
540
+ shows until that long after the last one this session (1440 is once per session); bars, teasers and
541
+ inline forms are never held back. A form with `audiences` needs personalisation consent.
542
+
543
+ | Option | |
544
+ |---|---|
545
+ | `audiences()` | The visitor's session audiences, when the page evaluates them: a form targeting none of them is skipped. Without it the server decides. |
546
+ | `subscribed()` | True for a visitor the page knows is subscribed, for forms with `exclude_subscribed`. |
547
+ | `servingHost`, `gatewayHost` | Where decisions and claims go (default: the host the SDK was pointed at) and capture goes (default: where events go). |
548
+ | `debug` | Log why each form did or did not show. Also on with `?hp_debug=onsite`. |
549
+ | `settings` | Development and tests: these forms instead of the settings'. |
550
+
551
+ `hp.onsite.debug()` returns each form's state and reasons (`armed`, `not eligible: per_session cap`,
552
+ `held out`, `shown`, …).
553
+
554
+ | `hp.onsite` | |
555
+ |---|---|
556
+ | `open(slot)`, `show(slot)` | Show a form now, past its trigger; resolves with whether it showed. |
557
+ | `on(name, fn)` | `shown`, `submitted`, `apply` or `closed`, with `{ slot, arm, format }`; `submitted` and `apply` add `coupon` (`{ code, prize }` or `null`), `closed` adds `reason`. A form with prizes says `submitted` once, when its claim settles: with the code, or `null` if the claim failed. Returns a function that stops listening. A throwing listener is logged, never breaks the form. |
558
+ | `suppress(reason)`, `unsuppress(reason?)` | Take any form off the screen quietly (not a dismissal) and keep forms away while any reason holds; `unsuppress()` lifts all. |
559
+
560
+ **Formats.** A `teaser` is a tab (its first step) that opens the rest as a popup; the tab is kept on
561
+ the session's later pages until opened or dismissed, and never traps focus. A `bar` with a
562
+ `progress` block is a basket bar: free-delivery progress from the basket value the SDK keeps from
563
+ `cartUpdated`, not shown for an empty basket. A `code` block on a first step is a promo reminder,
564
+ shown only with a code to remind of (the decision's `offer.code`, or the code this browser claimed,
565
+ kept 14 days); its `apply` button emits `apply`. `inline` renders into the trigger's `selector`, a
566
+ tile among products. Steps take a `layout` template (`stack`, `split`, `overlay`, `image-only`) with
567
+ image or muted, looping video media; see ADR 0064.
568
+
569
+ **Preview.** `?hp_preview=<token>` asks `POST /v1/decisions` for the previewed version and counts
570
+ nothing. For an editor, `import { mountPreview } from
571
+ '@thg-agentic/hyper-personalisation-web/onsite/preview'`; `mountPreview(host, creative, { step,
572
+ device: 'desktop' | 'mobile', consentText, smsConsentText, questions, prizes, basketValue })` draws the creative
573
+ inside `host` with no request, event or counter, and returns `{ update(creative, options), destroy(),
574
+ step() }`. It is not part of the runtime chunk.
575
+
576
+ **Rendering.** A shadow root on an `<hp-onsite>` element. Popup and fullscreen are a native
577
+ `<dialog>` opened with `showModal()` (focus trap, inert page, Esc, focus restore); sheet, bar and
578
+ teaser are non-modal (focus stays on the page); inline renders into the page's element and never
579
+ takes focus or scrolls the page. The close button is always there, and every control is at least 44px
580
+ to touch. Button text is dark or white, whichever contrasts more with the accent.
581
+ Text is the creative's RichText, built with DOM APIs only (text, bold, italic, https links), never
582
+ `innerHTML`. Under 640px a popup is a bottom sheet with a drag handle (drag down to close) and a split stacks;
583
+ `prefers-reduced-motion` stops the animation, video autoplay and the wheel's spin; a video pauses
584
+ while the page is hidden.
585
+
586
+ **Capture.** On submit the form fetches `GET /v1/capture/challenge` (started on the field's first
587
+ focus), solves the ALTCHA proof of work in a Worker made from a Blob (the main thread, yielding,
588
+ where a CSP refuses one), and posts `POST /v1/capture` to the ingest gateway with the address, the
589
+ consent text's id and version (`sms_consent_text` for a phone number, `consent_text` otherwise; a
590
+ consent block shows the same one), any answers and the decision. The field is emptied; only the receipt
591
+ is kept, in memory, for a spin's `POST /v1/offers/claim` (with the decision id when there is one;
592
+ a 503 is retried twice, after 0.5 s and 1.5 s). A question answered on a later step is posted to
593
+ `POST /v1/capture/answers` with the email step's address and receipt, for the destinations and
594
+ `Preferences Stated`; the address and receipt are kept only while the form is open. When every prize's codes are gone the form shows the
595
+ creative's `fallback` step, else a plain thanks, with no countdown and nothing to copy. Allow `blob:` in `worker-src` for the
596
+ Worker.
597
+
598
+ **Events**, each only with analytics consent, and none while previewing:
599
+
600
+ | When | Event |
601
+ |---|---|
602
+ | The form renders, or a held-out visitor's trigger fires | `Personalisation Exposed` (surface `decisions`, from the decisions response) |
603
+ | A sign-up succeeds | `Slot Engaged` `{ engagement: 'submit', step }`. The gateway sends `Newsletter Subscribed`, and `Preferences Stated` for answers sent with it |
604
+ | A question is answered after the sign-up | Sent by the gateway with `POST /v1/capture/answers` after an email step; otherwise `Preferences Stated` `{ source: 'onsite' }` from the page |
605
+ | Closed, Esc or declined before a sign-up | `Slot Engaged` `{ engagement: 'dismiss', step }` |
606
+ | A link button is clicked | `Slot Engaged` `{ engagement: 'click' }` |
607
+
608
+ The sign-up itself is sent whatever the analytics answer: it is the visitor's marketing consent.
609
+
610
+ **Privacy.** No contact data is kept in the browser. The caps are opaque counters per slot under
611
+ `storageNames.onsite` (`thg_hp_onsite`): shows this session (sessionStorage), and shows ever, last
612
+ close and submitted, plus one subscribed flag and the last claimed code (localStorage), and a
613
+ kept teaser tab (sessionStorage).
614
+
615
+ **Budgets**, enforced by `tests/onsite-chunks.test.mjs`, each about 5% over its chunk:
616
+ `onsite.min.js` under 5.875 KiB gzipped (about 5.6 KiB), `onsite-render.min.js` under 14 KiB (about
617
+ 13.7 KiB), so a page that shows a form loads about 19 KiB of onsite code and one whose forms never
618
+ fire about 5.6 KiB; `onsite-wheel.min.js` under 8 KiB (about 1.3 KiB), `onsite-answers.min.js` under
619
+ 2 KiB (about 1.5 KiB). The core carries only the loader.
620
+
621
+ Try it locally: `node tools/local/serve.mjs`, then
622
+ `http://localhost:8099/onsite.html?hp_debug=onsite` (offline mocks by default, `?mode=live` for
623
+ the local gateway and public API). `?example=<name>` shows one example creative from
624
+ `tools/local/testpage/onsite-examples.js` (hero, split, gif, spin, teaser, bar, sheet, gate, sms,
625
+ exit, inline, basket, promo, fullscreen), with local images under `testpage/media/`.
626
+
476
627
  ## Consent
477
628
 
478
629
  With a `consent` adapter the SDK is strict:
@@ -503,6 +654,10 @@ With a `consent` adapter the SDK is strict:
503
654
  - **Changes nothing announces:** after the first answer, an adapter whose `subscribe` cannot see
504
655
  every change (a cookie adapter on a browser without the Cookie Store API, or your own adapter
505
656
  without `observesChanges: true`) is re-read every 2 seconds while the page is visible.
657
+ - **Decisions made in another tab:** a CMP reports a change only in the tab where it was made
658
+ (OneTrust's `OnConsentChanged`, Didomi's `consent.changed`). Another tab already open applies
659
+ it at its next page load, as Segment's, RudderStack's and mParticle's OneTrust integrations and
660
+ Google Consent Mode do.
506
661
 
507
662
  - **Purposes the property does not ask for:** with the settings' `consent.purposes`, only the
508
663
  purposes listed as `true` are reported (analytics always is). The others read as unanswered in
@@ -527,7 +682,7 @@ All adapters are imported from `@thg-agentic/hyper-personalisation-web/consent`.
527
682
  | Adapter | Reads | Not answered while |
528
683
  |---|---|---|
529
684
  | `didomiConsent({ purposes })` | `Didomi.getCurrentUserStatus().purposes`, subscribed via `didomiEventListeners` / `consent.changed` | Didomi is not loaded, `shouldUserStatusBeCollected()` is true, or `isUserStatusPartial()` is true |
530
- | `oneTrustConsent({ groups, impliedConsent })` | `OnetrustActiveGroups`, else the `OptanonConsent` cookie's `groups=`; subscribed via `OneTrustGroupsUpdated`, `OptanonWrapper` and `OneTrust.OnConsentChanged` | The banner was never closed (`OptanonAlertBoxClosed`) and never interacted with (`interactionCount`) |
685
+ | `oneTrustConsent({ groups, impliedConsent })` | The `OptanonConsent` cookie's `groups=`, else `OnetrustActiveGroups` before OneTrust has written it; subscribed via `OneTrustGroupsUpdated`, `OptanonWrapper` and `OneTrust.OnConsentChanged` | The banner was never closed (`OptanonAlertBoxClosed`) and never interacted with (`interactionCount`) |
531
686
  | `cookieConsent({ name, categories, parse, refusedValue, source, changeEvent })` | A first-party cookie your banner writes; subscribed via a `thg_hp_consent_change` window event (`changeEvent`) and Cookie Store `change` | The cookie is absent, or its value cannot be parsed |
532
687
  | `staticConsent(state)` | The state you pass, for sites with no CMP | Never |
533
688
 
@@ -838,6 +993,7 @@ SDK per page.
838
993
  | `session` | `thg_hp_session` | cookie, one per browser | the session id the SDK keeps when you name none, 30 minutes from the last event; only under a grant (3.15, see [Session ids](#session-ids)) |
839
994
  | `preview` | `thg_hp_preview` | session cookie | a trader's preview token from `?hp_preview=` (3.15, see [Preview](#preview)) |
840
995
  | `debug` | `thg_hp_debug` | sessionStorage | a developer's debug token from `?hp_debug=` (3.18), checked against the settings on every page |
996
+ | `onsite` | `thg_hp_onsite:<property>` | localStorage, sessionStorage | onsite forms' opaque counters per slot and a subscribed flag; never contact data (see [Onsite forms](#onsite-forms)) |
841
997
  | — | `thg_hp_conformance_page_type` | sessionStorage | the previous page's type, only while the development-only conformance checker runs |
842
998
 
843
999
  Keys are scoped by property because one origin can serve several tenants. The names the SDK used