@thg-agentic/hyper-personalisation-web 3.22.2 → 3.24.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.16, 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
 
@@ -483,6 +485,167 @@ const badges = await proof.forProducts(['123', '456']); // GET /v1/social-proof,
483
485
  It sends the key and `hp-consent` itself; `propertyId` is needed only for a key scoped to several
484
486
  properties.
485
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 popup's next step takes its own height with its top where the last step's
511
+ was, so only the card's bottom edge moves: nothing on it shifts, and a short step (the thanks after
512
+ a spin) never sits in a tall step's empty space. A step too tall for the room below that top is
513
+ centred again. A bar, sheet or inline form keeps at least its size, its content centred, so its top
514
+ edge (or the page under it) never moves. An image with `widths` (the smaller copies the console's
515
+ upload made, any widths, at `-<w>w.webp`) is a `srcset` with the original at its `width`, and
516
+ `sizes` for where it shows: the whole screen under 640px, else the card's width (46% of it beside a
517
+ split).
518
+
519
+ **Pictures.** Once a form is decided, its first step's pictures (its media, image blocks and a
520
+ video's poster) are fetched and decoded with the same `srcset` and `sizes` the form will use, and
521
+ the form opens when they are ready, or after 300 ms at most, so it never opens on an empty picture.
522
+ The first step's images load eagerly at `fetchpriority="high"`; while a step shows, the pictures of
523
+ the steps it leads to are warmed once the page has loaded and the browser is idle
524
+ (`requestIdleCallback`, two seconds at most), and later steps' images load lazily. A video plays
525
+ muted and inline only without reduced motion; under it only the poster shows and the video is never
526
+ downloaded (`preload="none"`). A video with other encodings in `sources` offers WebM first, then
527
+ MP4.
528
+
529
+ **Keyboard.** A popup or full-screen form is modal: focus starts on its heading, and Tab and
530
+ Shift-Tab cycle through its controls without leaving it; Esc closes it.
531
+
532
+
533
+ **Questions.** A choice (`one`, `many`) shows as buttons, a list of radio buttons or checkboxes, or
534
+ a dropdown, as its question block's `display` says (a multiple choice is never a dropdown). A
535
+ `date` question is a birthday's day and month selects and, when asked, a year field, in the order
536
+ and month names of the form's `locale` (the page's `lang` when the form has none); or the browser's
537
+ own date field for any other date. A `number` question is a text field with a numeric keyboard and
538
+ its unit. A half-filled date, or a number out of the question's bounds or the gateway's limits (ten
539
+ digits, at most a billion either way), is the shopper's to correct before the step moves on; the
540
+ gateway checks every answer again. Answers are stored as option values, `YYYY-MM-DD` or `--MM-DD`,
541
+ and digits. A form that asks a date or a number loads their fields once, as it mounts; if they
542
+ cannot load, the question says it is not available and the form goes on without that answer (every
543
+ question is optional). A phone field's country code starts on the block's `country`, else the
544
+ region of the form's `locale`.
545
+
546
+ **How a form shows.** The browser judges the trigger; the server decides the rest. Each live form's
547
+ trigger is watched: `entry` (`delay_ms`), `exit`, `scroll` (`depth`), `time` and `idle`
548
+ (`seconds`), `inline` (rendered into `selector`) and `manual` (`hp.onsite.open(slot)`). Exit intent
549
+ on desktop arms after `delay_ms` (5 s) and once the pointer has been below the top tenth of the
550
+ viewport, and fires when it leaves through the top edge, moving up. On mobile it is a fast upward
551
+ flick (120px at 0.5 px/ms) after 30% of the page, or a return to the tab after 10 s away. Pages
552
+ (include/exclude globs on the path, `*` matching anything, in any case), devices (`mobile` is a coarse pointer
553
+ or under 768px), dates and caps are checked first; then `POST /v1/decisions` is asked for that one
554
+ slot. A held-out visitor, or no decision, sees nothing. One overlay per page view: of the forms that
555
+ fire within 50 ms, the highest `priority`; once one has shown (or the visitor was held out of it),
556
+ closing it lets no other form in on that page view. A `manual` form, which the site opens on the
557
+ shopper's click (its own sign-up button), is the shopper asking: it opens whatever its caps, the gap
558
+ and the page view's allowance say, as Klaviyo's custom-triggered forms do, though never over a
559
+ modal on screen (a popup, or a teaser opened from its tab); its pages, devices, dates and audiences
560
+ still hold. Asked for twice while it opens, it opens once. With the property's `overlay_gap_minutes`, no popup, sheet or full-screen form
561
+ shows until that long after the last one this session (1440 is once per session); bars, teasers and
562
+ inline forms are never held back. A form with `audiences` needs personalisation consent.
563
+
564
+ | Option | |
565
+ |---|---|
566
+ | `audiences()` | The visitor's session audiences, when the page evaluates them: a form targeting none of them is skipped. Without it the server decides. |
567
+ | `subscribed()` | True for a visitor the page knows is subscribed, for forms with `exclude_subscribed`. |
568
+ | `servingHost`, `gatewayHost` | Where decisions and claims go (default: the host the SDK was pointed at) and capture goes (default: where events go). |
569
+ | `debug` | Log why each form did or did not show. Also on with `?hp_debug=onsite`. |
570
+ | `settings` | Development and tests: these forms instead of the settings'. |
571
+
572
+ `hp.onsite.debug()` returns each form's state and reasons (`armed`, `not eligible: per_session cap`,
573
+ `held out`, `shown`, …).
574
+
575
+ | `hp.onsite` | |
576
+ |---|---|
577
+ | `open(name)`, `show(name)` | Show a form now, past its trigger; resolves with whether it showed. `name` is a form's slot, else one of a form's `aliases` (the settings' names for it: `footer_signup` for the site's own sign-up button), matched exactly; a slot wins over an alias. A `manual` form, or one opened by an alias, shows each time (see above); any other keeps to its caps. From there it is that form, by its slot, in every event. |
578
+ | `on(name, fn)` | `shown`, `submitted`, `apply`, `closed` or `held`, with `{ slot, arm, format }`; `submitted` and `apply` add `coupon` (`{ code, prize }` or `null`), `closed` adds `reason`. `held` is a held-out visitor whose trigger fired (a form the site opened says so by `open` resolving `false` instead), so a site replacing its own popup with HP's shows its own there and the holdout compares the two. 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. |
579
+ | `ready()` | Resolves once the page knows whether onsite forms run on it: each form's state, as `debug()`, once the runtime has started, or `[]` once none will (not opted in, no live form, the runtime failed to load). A site keeping its own popup where HP has none for the visitor reads it. |
580
+ | `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. |
581
+
582
+ **Formats.** A `teaser` is a tab (its first step) that opens the rest as a popup; the tab is kept on
583
+ the session's later pages until opened or dismissed, and never traps focus. A `bar` with a
584
+ `progress` block is a basket bar: free-delivery progress from the basket value the SDK keeps from
585
+ `cartUpdated`, not shown for an empty basket. A `code` block on a first step is a promo reminder,
586
+ shown only with a code to remind of (the decision's `offer.code`, or the code this browser claimed,
587
+ kept 14 days); its `apply` button emits `apply`. `inline` renders into the trigger's `selector`, a
588
+ tile among products. Steps take a `layout` template (`stack`, `split`, `overlay`, `image-only`) with
589
+ image or muted, looping video media; see ADR 0064.
590
+
591
+ **Preview.** `?hp_preview=<token>` asks `POST /v1/decisions` for the previewed version and counts
592
+ nothing. For an editor, `import { mountPreview } from
593
+ '@thg-agentic/hyper-personalisation-web/onsite/preview'`; `mountPreview(host, creative, { step,
594
+ device: 'desktop' | 'mobile', consentText, smsConsentText, questions, prizes, basketValue })` draws the creative
595
+ inside `host` with no request, event or counter, and returns `{ update(creative, options), destroy(),
596
+ step() }`. It is not part of the runtime chunk.
597
+
598
+ **Rendering.** A shadow root on an `<hp-onsite>` element. Popup and fullscreen are a native
599
+ `<dialog>` opened with `showModal()` (focus trap, inert page, Esc, focus restore); sheet, bar and
600
+ teaser are non-modal (focus stays on the page); inline renders into the page's element and never
601
+ takes focus or scrolls the page. The close button is always there, and every control is at least 44px
602
+ to touch. Button text is dark or white, whichever contrasts more with the accent.
603
+ Text is the creative's RichText, built with DOM APIs only (text, bold, italic, https links), never
604
+ `innerHTML`. Under 640px a popup is a bottom sheet with a drag handle (drag down to close) and a split stacks;
605
+ `prefers-reduced-motion` stops the animation, video autoplay and the wheel's spin; a video pauses
606
+ while the page is hidden.
607
+
608
+ **Capture.** On submit the form fetches `GET /v1/capture/challenge` (started on the field's first
609
+ focus), solves the ALTCHA proof of work in a Worker made from a Blob (the main thread, yielding,
610
+ where a CSP refuses one), and posts `POST /v1/capture` to the ingest gateway with the address, the
611
+ consent text's id and version (`sms_consent_text` for a phone number, `consent_text` otherwise; a
612
+ consent block shows the same one), any answers and the decision. The field is emptied; only the receipt
613
+ is kept, in memory, for a spin's `POST /v1/offers/claim` (with the decision id when there is one;
614
+ a 503 is retried twice, after 0.5 s and 1.5 s). A question answered on a later step is posted to
615
+ `POST /v1/capture/answers` with the email step's address and receipt, for the destinations and
616
+ `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
617
+ creative's `fallback` step, else a plain thanks, with no countdown and nothing to copy. Allow `blob:` in `worker-src` for the
618
+ Worker.
619
+
620
+ **Events**, each only with analytics consent, and none while previewing:
621
+
622
+ | When | Event |
623
+ |---|---|
624
+ | The form renders, or a held-out visitor's trigger fires | `Personalisation Exposed` (surface `decisions`, from the decisions response) |
625
+ | A sign-up succeeds | `Slot Engaged` `{ engagement: 'submit', step }`. The gateway sends `Newsletter Subscribed`, and `Preferences Stated` for answers sent with it |
626
+ | 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 |
627
+ | Closed, Esc or declined before a sign-up | `Slot Engaged` `{ engagement: 'dismiss', step }` |
628
+ | A link button is clicked | `Slot Engaged` `{ engagement: 'click' }` |
629
+
630
+ The sign-up itself is sent whatever the analytics answer: it is the visitor's marketing consent.
631
+
632
+ **Privacy.** No contact data is kept in the browser. The caps are opaque counters per slot under
633
+ `storageNames.onsite` (`thg_hp_onsite`): shows this session (sessionStorage), and shows ever, last
634
+ close and submitted, plus one subscribed flag and the last claimed code (localStorage), and a
635
+ kept teaser tab (sessionStorage).
636
+
637
+ **Budgets**, enforced by `tests/onsite-chunks.test.mjs`, each about 5% over its chunk:
638
+ `onsite.min.js` under 5.875 KiB gzipped (about 5.6 KiB), `onsite-render.min.js` under 14 KiB (about
639
+ 13.7 KiB), so a page that shows a form loads about 19 KiB of onsite code and one whose forms never
640
+ fire about 5.6 KiB; `onsite-wheel.min.js` under 8 KiB (about 1.3 KiB), `onsite-answers.min.js` under
641
+ 2 KiB (about 1.5 KiB). The core carries only the loader.
642
+
643
+ Try it locally: `node tools/local/serve.mjs`, then
644
+ `http://localhost:8099/onsite.html?hp_debug=onsite` (offline mocks by default, `?mode=live` for
645
+ the local gateway and public API). `?example=<name>` shows one example creative from
646
+ `tools/local/testpage/onsite-examples.js` (hero, split, gif, spin, teaser, bar, sheet, gate, sms,
647
+ exit, inline, basket, promo, fullscreen), with local images under `testpage/media/`.
648
+
486
649
  ## Consent
487
650
 
488
651
  With a `consent` adapter the SDK is strict:
@@ -852,6 +1015,7 @@ SDK per page.
852
1015
  | `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)) |
853
1016
  | `preview` | `thg_hp_preview` | session cookie | a trader's preview token from `?hp_preview=` (3.15, see [Preview](#preview)) |
854
1017
  | `debug` | `thg_hp_debug` | sessionStorage | a developer's debug token from `?hp_debug=` (3.18), checked against the settings on every page |
1018
+ | `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)) |
855
1019
  | — | `thg_hp_conformance_page_type` | sessionStorage | the previous page's type, only while the development-only conformance checker runs |
856
1020
 
857
1021
  Keys are scoped by property because one origin can serve several tenants. The names the SDK used
@@ -913,7 +1077,7 @@ would see the CDN's IP and country instead of the shopper's.
913
1077
  | Release | `DEFAULT_API_HOST` | For |
914
1078
  |---|---|---|
915
1079
  | 3.12 to 3.16 | A staging host | Being retired. Upgrade. |
916
- | 3.17 | `https://hp.uat.thgingenuity.com` | Interim: HP's nonprod stack. Test keys only. |
1080
+ | 3.17 | HP's nonprod host | Interim, test keys only. Upgrade. |
917
1081
  | 3.18 | `https://hp.thgingenuity.com` | Production, for test and live keys alike. Go live on this. |
918
1082
 
919
1083
  The key, not the host, keeps test traffic out of live results, so UAT and preview builds use the