@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.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The wheel, only for a form that spins: a bundler splits this import out of ./onsite, and the CDN
3
+ * build swaps this module for cdn/wheel-loader.js, which loads dist/onsite-wheel.min.js.
4
+ */
5
+ export const loadWheel = () => import('./wheel.js');
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The spin-to-win wheel (`./onsite/wheel`), a chunk of its own: an SVG of the form's prize labels
3
+ * that only animates, in the theme's colours: the accent and its tints, a rim of lights, a hub and
4
+ * a pointer. The prize is drawn on the server (`POST /v1/offers/claim`), so the wheel is told where
5
+ * to stop; the segments are equal, and say nothing about the odds. The angle math is ours, after
6
+ * spin-wheel's spinToItem (CrazyTim, MIT).
7
+ */
8
+
9
+ import { restAngle } from './rules.js';
10
+
11
+ const NS = 'http://www.w3.org/2000/svg';
12
+ const SPIN_MS = 4500;
13
+ const MAX_LABEL = 16;
14
+ const ACCENT = 'var(--hp-accent,#111)';
15
+ const ON_ACCENT = 'var(--hp-on-accent,#fff)';
16
+ const BG = 'var(--hp-bg,#fff)';
17
+ const TEXT = 'var(--hp-text,#111)';
18
+
19
+ function make(tag, attrs = {}) {
20
+ const node = document.createElementNS(NS, tag);
21
+ for (const [key, value] of Object.entries(attrs))
22
+ node.setAttribute(key, String(value));
23
+ return node;
24
+ }
25
+
26
+ /** Colours as style, not attributes, so the theme's custom properties resolve. */
27
+ function paint(node, fill, stroke, opacity = 1) {
28
+ node.style.fill = fill;
29
+ if (opacity < 1) node.style.fillOpacity = String(opacity);
30
+ if (stroke) node.style.stroke = stroke;
31
+ return node;
32
+ }
33
+
34
+ const reducedMotion = () =>
35
+ globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
36
+
37
+ const point = (deg, r = 1) => {
38
+ const rad = ((deg - 90) * Math.PI) / 180;
39
+ return `${(r * Math.cos(rad)).toFixed(4)} ${(r * Math.sin(rad)).toFixed(4)}`;
40
+ };
41
+
42
+ /**
43
+ * Draw the wheel for `prizes` (`[{ key, label }]`) into `container`. Null for fewer than two.
44
+ *
45
+ * @returns {{ spinTo(index: number): Promise<void>, remove(): void } | null}
46
+ */
47
+ export function mountWheel(container, prizes) {
48
+ const labels = (Array.isArray(prizes) ? prizes : []).map((p) =>
49
+ String(p?.label ?? p?.key ?? ''),
50
+ );
51
+ const n = labels.length;
52
+ if (n < 2) return null;
53
+ const size = 360 / n;
54
+ const svg = make('svg', {
55
+ viewBox: '-1.2 -1.2 2.4 2.4',
56
+ role: 'img',
57
+ 'aria-label': `Prize wheel: ${labels.join(', ')}`,
58
+ });
59
+ const wheel = make('g');
60
+ // Spun about its own box, the unit circle round 0 0. A view-box origin sits at the user space's
61
+ // 0 0, not the viewBox's corner, so 50% there is off centre and the wheel swings out of its rim.
62
+ wheel.style.transformBox = 'fill-box';
63
+ wheel.style.transformOrigin = '50% 50%';
64
+ // The accent, a tint of it over the background, and for an odd count a third tone, so no two
65
+ // neighbours match.
66
+ const tones = [
67
+ [1, ON_ACCENT],
68
+ [0.14, TEXT],
69
+ [0.72, ON_ACCENT],
70
+ ];
71
+ wheel.append(paint(make('circle', { r: 1 }), BG));
72
+ const lights = [];
73
+ labels.forEach((label, i) => {
74
+ const [tone, ink] = tones[n % 2 === 1 && i === n - 1 ? 2 : i % 2];
75
+ wheel.append(
76
+ paint(
77
+ make('path', {
78
+ d: `M0 0L${point(i * size)}A1 1 0 ${size > 180 ? 1 : 0} 1 ${point((i + 1) * size)}Z`,
79
+ 'stroke-width': 0.012,
80
+ }),
81
+ ACCENT,
82
+ BG,
83
+ tone,
84
+ ),
85
+ );
86
+ // Along its slice's middle; on the left half turned about its own centre, so at rest no label
87
+ // reads upside down.
88
+ const angle = (i + 0.5) * size - 90;
89
+ const text = paint(
90
+ make('text', {
91
+ transform: `rotate(${angle}) translate(0.6 0)${angle > 90 && angle < 270 ? ' rotate(180)' : ''}`,
92
+ 'text-anchor': 'middle',
93
+ 'dominant-baseline': 'central',
94
+ 'font-size': n > 6 ? 0.09 : 0.105,
95
+ 'font-weight': 700,
96
+ 'aria-hidden': 'true',
97
+ }),
98
+ ink,
99
+ );
100
+ text.textContent =
101
+ label.length > MAX_LABEL ? `${label.slice(0, MAX_LABEL - 1)}…` : label;
102
+ wheel.append(text);
103
+ for (const at of [i * size, (i + 0.5) * size]) {
104
+ const [cx, cy] = point(at, 1.075).split(' ');
105
+ lights.push(paint(make('circle', { cx, cy, r: 0.024 }), BG));
106
+ }
107
+ });
108
+ svg.append(
109
+ paint(make('circle', { r: 1.13 }), TEXT),
110
+ ...lights,
111
+ wheel,
112
+ paint(make('circle', { r: 0.17, 'stroke-width': 0.035 }), BG, TEXT),
113
+ paint(make('circle', { r: 0.065 }), ACCENT),
114
+ paint(
115
+ make('path', {
116
+ d: 'M-0.12 -1.2L0.12 -1.2L0 -0.86Z',
117
+ 'stroke-width': 0.03,
118
+ 'stroke-linejoin': 'round',
119
+ }),
120
+ ACCENT,
121
+ BG,
122
+ ),
123
+ );
124
+ container.append(svg);
125
+
126
+ return {
127
+ spinTo(index) {
128
+ const still = reducedMotion();
129
+ wheel.style.transition = still
130
+ ? 'none'
131
+ : `transform ${SPIN_MS}ms cubic-bezier(.15,.7,.1,1)`;
132
+ wheel.style.transform = `rotate(${restAngle(index, labels.length, still ? 0 : 5)}deg)`;
133
+ if (still) return Promise.resolve();
134
+ return new Promise((resolve) => {
135
+ const done = setTimeout(resolve, SPIN_MS + 200);
136
+ wheel.addEventListener(
137
+ 'transitionend',
138
+ () => {
139
+ clearTimeout(done);
140
+ resolve();
141
+ },
142
+ { once: true },
143
+ );
144
+ });
145
+ },
146
+ remove: () => svg.remove(),
147
+ };
148
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `hp.onsite`: open (`show`) a form by hand, listen to the forms, keep the screen clear, or read why
3
+ * each form did or did not show, all callable before the onsite runtime has loaded. Listeners and
4
+ * suppressions wait on the page for the runtime. An `open` made before it started waits for it, and resolves with what
5
+ * the runtime did, or `false` once the page knows no runtime will start; nothing here throws.
6
+ */
7
+
8
+ const CONTROLLER = Symbol.for('@thg-agentic/hyper-personalisation-web.onsite');
9
+ const PENDING = Symbol.for(
10
+ '@thg-agentic/hyper-personalisation-web.onsite.pending',
11
+ );
12
+ const LISTENERS = Symbol.for(
13
+ '@thg-agentic/hyper-personalisation-web.onsite.listeners',
14
+ );
15
+ const SUPPRESSED = Symbol.for(
16
+ '@thg-agentic/hyper-personalisation-web.onsite.suppressed',
17
+ );
18
+ const EVENTS = ['shown', 'submitted', 'apply', 'closed'];
19
+ const MAX_PENDING = 10;
20
+
21
+ const page = () => (typeof window === 'undefined' ? null : window);
22
+
23
+ /** No onsite runtime will start on this page: every `open` waiting for one resolves `false`. */
24
+ export function dropPendingOpens() {
25
+ try {
26
+ for (const { resolve } of page()?.[PENDING]?.splice(0) ?? [])
27
+ resolve(false);
28
+ } catch {
29
+ /* never into the page */
30
+ }
31
+ }
32
+
33
+ export const onsite = Object.freeze({
34
+ /** Show the form on `slot` now, if its rules allow it. Resolves to whether it was shown. */
35
+ open(slot) {
36
+ try {
37
+ const w = page();
38
+ if (!w || typeof slot !== 'string') return Promise.resolve(false);
39
+ if (w[CONTROLLER]) return w[CONTROLLER].open(slot);
40
+ w[PENDING] ??= [];
41
+ if (w[PENDING].length < MAX_PENDING)
42
+ return new Promise((resolve) => w[PENDING].push({ slot, resolve }));
43
+ } catch {
44
+ /* never into the page */
45
+ }
46
+ return Promise.resolve(false);
47
+ },
48
+ /** The same as `open`, by the name the console's snippets use. */
49
+ show(slot) {
50
+ return onsite.open(slot);
51
+ },
52
+ /**
53
+ * Listen to the forms: `shown`, `submitted` (`{ coupon }`: the claimed code for a form that
54
+ * spins, else null), `apply` (`{ coupon }`: a reminder's code to apply) and `closed`
55
+ * (`{ reason }`). Each detail carries the `slot`, `arm` and `format`. Returns a function that
56
+ * stops listening.
57
+ */
58
+ on(name, fn) {
59
+ try {
60
+ const w = page();
61
+ if (!w || !EVENTS.includes(name) || typeof fn !== 'function')
62
+ return () => {};
63
+ w[LISTENERS] ??= {};
64
+ (w[LISTENERS][name] ??= new Set()).add(fn);
65
+ return () => w[LISTENERS][name].delete(fn);
66
+ } catch {
67
+ return () => {};
68
+ }
69
+ },
70
+ /** Keep the screen clear, for `reason`: a form showing goes, quietly, and none shows until every reason is lifted. */
71
+ suppress(reason = 'page') {
72
+ try {
73
+ const w = page();
74
+ if (!w) return;
75
+ (w[SUPPRESSED] ??= new Set()).add(String(reason));
76
+ w[CONTROLLER]?.suppress();
77
+ } catch {
78
+ /* never into the page */
79
+ }
80
+ },
81
+ /** Lift `reason`, or every reason when none is given. */
82
+ unsuppress(reason) {
83
+ try {
84
+ const set = page()?.[SUPPRESSED];
85
+ if (reason === undefined) set?.clear();
86
+ else set?.delete(String(reason));
87
+ } catch {
88
+ /* never into the page */
89
+ }
90
+ },
91
+ /** Each form's eligibility on this page, and why: `[]` until the runtime has started. */
92
+ debug() {
93
+ try {
94
+ return page()?.[CONTROLLER]?.debug() ?? [];
95
+ } catch {
96
+ return [];
97
+ }
98
+ },
99
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The onsite runtime, as a separate chunk: a bundler splits this import out of the core, and the
3
+ * CDN build swaps this module for cdn/onsite-loader.js, which loads dist/onsite.min.js.
4
+ */
5
+ export const loadOnsite = () => import('./onsite/index.js');
package/src/settings.js CHANGED
@@ -156,6 +156,22 @@ function readAnonymousId(raw) {
156
156
  : null;
157
157
  }
158
158
 
159
+ /**
160
+ * The live onsite forms (`hp.onsite`), or null when there are none. Only the shape is checked here:
161
+ * ./onsite reads each form itself, so the core never grows with the forms' rules.
162
+ */
163
+ function readOnsite(raw) {
164
+ const forms = Array.isArray(raw?.forms) ? raw.forms.filter(isObject) : [];
165
+ if (forms.length === 0) return null;
166
+ return {
167
+ forms,
168
+ capture: isObject(raw.capture) ? raw.capture : null,
169
+ ...(Number.isInteger(raw.overlay_gap_minutes)
170
+ ? { overlay_gap_minutes: raw.overlay_gap_minutes }
171
+ : {}),
172
+ };
173
+ }
174
+
159
175
  /**
160
176
  * Whether a 2xx body is a settings answer at all: an `hp` block saying on or off. Anything else (a
161
177
  * captive portal's page, a proxy's error) is no answer, so the last good settings and what was
@@ -174,6 +190,7 @@ export function isSettingsAnswer(body) {
174
190
  export function readSettings(body) {
175
191
  const hp = isObject(body) ? body.hp : null;
176
192
  if (!isObject(hp) || hp.enabled !== true) return DISABLED;
193
+ const onsite = readOnsite(hp.onsite);
177
194
  return {
178
195
  enabled: true,
179
196
  propertyId:
@@ -192,6 +209,7 @@ export function readSettings(body) {
192
209
  // The share of visitors sent for (3.18). Anything that is not a share sends for everyone.
193
210
  sampleRate: isFraction(hp.sampleRate) ? hp.sampleRate : 1,
194
211
  debug: readDebug(hp.debug),
212
+ ...(onsite && { onsite }),
195
213
  plan:
196
214
  isObject(body.plan) && isObject(body.plan.track)
197
215
  ? { track: body.plan.track }
@@ -1,4 +1,4 @@
1
1
  // Generated from the spec by scripts/build-types.mjs. DO NOT EDIT: run `pnpm run build:types`.
2
2
 
3
3
  /** The event specification this SDK emits, stamped on every event as context.spec.version. */
4
- export const SPEC_VERSION = '1.15';
4
+ export const SPEC_VERSION = '1.18';
@@ -59,6 +59,11 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
59
59
  * (`?hp_debug=`), checked against the settings on every page (3.18).
60
60
  */
61
61
  debug: 'thg_hp_debug',
62
+ /**
63
+ * localStorage and sessionStorage, per property: onsite forms' opaque counters per slot (shown,
64
+ * last closed, submitted) and a subscribed flag. Never contact data (onsite).
65
+ */
66
+ onsite: 'thg_hp_onsite',
62
67
  });
63
68
 
64
69
  /**
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.22.0';
1
+ export const VERSION = '3.23.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -19,6 +19,7 @@ import type {
19
19
  PageType,
20
20
  StorageNames,
21
21
  } from './index';
22
+ import type { OnsiteFacade, OnsiteOptions, OnsiteSettings } from './onsite';
22
23
 
23
24
  /** Everything the page itself configures: the rest is the tenant's, and comes from settings. */
24
25
  export interface HpConfig {
@@ -68,6 +69,8 @@ export interface SettingsResponse {
68
69
  string,
69
70
  { share: number; salt: string; readoutSlots?: string[] }
70
71
  >;
72
+ /** The live onsite forms (ADR 0064). Started only for a page that passes `onsite`. */
73
+ onsite?: OnsiteSettings;
71
74
  };
72
75
  }
73
76
 
@@ -148,6 +151,17 @@ export interface BootstrapOptions {
148
151
  settings?: SettingsResponse;
149
152
  /** createAnalytics options that are functions, e.g. getRecommendations or getExtensions. */
150
153
  extend?: Partial<AnalyticsOptions>;
154
+ /**
155
+ * Onsite forms: `true` (or the options) loads `./onsite` as a separate chunk when the settings
156
+ * have a live form, and starts it. Absent or false: never loaded.
157
+ */
158
+ onsite?: boolean | OnsiteOptions;
159
+ }
160
+
161
+ /** The client bootstrap resolves to: the lower-level client, plus `onsite`. */
162
+ export interface BootstrappedClient extends AnalyticsClient {
163
+ /** Open a form by hand, or read why each form did or did not show (`[]` before it started). */
164
+ onsite: OnsiteFacade;
151
165
  }
152
166
 
153
167
  /**
@@ -157,7 +171,7 @@ export interface BootstrapOptions {
157
171
  export declare function bootstrap(
158
172
  config: HpConfig,
159
173
  options: BootstrapOptions,
160
- ): Promise<AnalyticsClient | null>;
174
+ ): Promise<BootstrappedClient | null>;
161
175
  /** Every reason the config and options cannot be used; empty when they can. */
162
176
  export declare function validateConfig(
163
177
  config: unknown,
package/types/events.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Generated from spec/registry.json by scripts/generate-sdk-models.mjs.
2
2
  // DO NOT EDIT. Change the schema and regenerate — the schema is the contract,
3
3
  // and a hand-edit here is an SDK that disagrees with the ingest.
4
- // Spec version: 1.15
4
+ // Spec version: 1.18
5
5
 
6
6
  /** context.source.environment. Environment the producer runs in. */
7
7
  export type CdpSourceEnvironment = 'production' | 'staging' | 'development';
@@ -76,12 +76,28 @@ export interface DecisionMade {
76
76
  latency_ms?: number | null;
77
77
  }
78
78
 
79
- /** A visitor interacted with a personalised surface: viewed it, clicked it (for example opened a product from a rail), or dismissed it. */
79
+ /** A triggered journey fired for one shopper: an entry event happened, the journey's delay passed, its exit event did not happen, and the shopper passed its gates (marketing consent, suppression, frequency cap, quiet hours). */
80
+ export interface JourneyTriggered {
81
+ trigger_id: string;
82
+ journey: string;
83
+ entry_event: string;
84
+ entry_message_id: string;
85
+ entry_at: string;
86
+ delay_seconds: number;
87
+ holdout: boolean;
88
+ holdout_share?: number | null;
89
+ cart_id?: string | null;
90
+ currency?: string | null;
91
+ products?: CdpProduct[];
92
+ }
93
+
94
+ /** A visitor acted on a personalised surface: clicked it (for example opened a product from a rail), dismissed it, or submitted a step of an onsite form. */
80
95
  export interface SlotEngaged {
81
96
  decision_id: string;
82
97
  slot: string;
83
- engagement: 'view' | 'click' | 'dismiss';
98
+ engagement: 'view' | 'click' | 'dismiss' | 'submit';
84
99
  arm?: string | null;
100
+ step?: string | null;
85
101
  position?: number | null;
86
102
  products?: CdpProduct[] | null;
87
103
  }
@@ -432,13 +448,18 @@ export interface SignedUp {
432
448
  /** The shopper signed out. */
433
449
  export type SignedOut = Record<string, never>;
434
450
 
435
- /** The shopper opted in to marketing messages on the site or in the app, for example through a footer form, a modal, or a checkbox at checkout or in their account. */
451
+ /** The shopper opted in to marketing messages on the site or in the app, for example through a footer form, an onsite sign-up form, or a checkbox at checkout or in their account. */
436
452
  export interface NewsletterSubscribed {
437
453
  email_sha256: string;
438
454
  requires_verification?: boolean | null;
439
455
  placement?: string | null;
440
456
  channel?: 'email' | 'sms' | 'post' | 'push' | null;
441
457
  phone_sha256?: string | null;
458
+ slot?: string | null;
459
+ arm?: string | null;
460
+ decision_id?: string | null;
461
+ receipt_id?: string | null;
462
+ consent_text?: string | null;
442
463
  }
443
464
 
444
465
  /** A shopper submitted a review of a product. */
@@ -630,12 +651,47 @@ export interface ApplicationUpdated {
630
651
  build: string;
631
652
  }
632
653
 
654
+ /** The shopper answered a preference question, for example what they are shopping for or which categories they want to hear about. */
655
+ export interface PreferencesStated {
656
+ question: string;
657
+ values: string[];
658
+ source: 'onsite' | 'account' | 'quiz';
659
+ slot?: string | null;
660
+ arm?: string | null;
661
+ decision_id?: string | null;
662
+ step?: string | null;
663
+ }
664
+
665
+ /** A shopper who signed up through an onsite form claimed its offer, for example the prize of a spin-to-win or a welcome discount. */
666
+ export interface OfferClaimed {
667
+ slot: string;
668
+ prize: string;
669
+ email_sha256: string;
670
+ fallback: boolean;
671
+ arm?: string | null;
672
+ decision_id?: string | null;
673
+ pool?: string | null;
674
+ code_sha256?: string | null;
675
+ }
676
+
677
+ /** Hyper-Personalisation delivered a sign-up from an onsite form to one of the brand's systems of record, such as its account service or its email platform, or gave up trying. */
678
+ export interface SignUpRelayed {
679
+ receipt_id: string;
680
+ destination: string;
681
+ kind: 'thg' | 'listrak' | 'klaviyo' | 'webhook';
682
+ outcome: 'delivered' | 'duplicate' | 'verify' | 'failed';
683
+ attempts: number;
684
+ email_sha256?: string | null;
685
+ phone_sha256?: string | null;
686
+ }
687
+
633
688
  /** The spec version these models implement. SDKs stamp it on context.spec.version. */
634
- export const SPEC_VERSION = '1.15';
689
+ export const SPEC_VERSION = '1.18';
635
690
 
636
691
  /** Every event name the registry accepts. Anything else is rejected at the ingest. */
637
692
  export type CdpEventName =
638
693
  | 'Decision Made'
694
+ | 'Journey Triggered'
639
695
  | 'Slot Engaged'
640
696
  | 'Recommendations Viewed'
641
697
  | 'Promotion Viewed'
@@ -690,11 +746,15 @@ export type CdpEventName =
690
746
  | 'Application Opened'
691
747
  | 'Application Backgrounded'
692
748
  | 'Application Updated'
749
+ | 'Preferences Stated'
750
+ | 'Offer Claimed'
751
+ | 'Sign-up Relayed'
693
752
  ;
694
753
 
695
754
  /** Event name -> its properties, so a generic emitter stays type-safe. */
696
755
  export interface CdpEventProperties {
697
756
  'Decision Made': DecisionMade;
757
+ 'Journey Triggered': JourneyTriggered;
698
758
  'Slot Engaged': SlotEngaged;
699
759
  'Recommendations Viewed': RecommendationsViewed;
700
760
  'Promotion Viewed': PromotionViewed;
@@ -749,6 +809,9 @@ export interface CdpEventProperties {
749
809
  'Application Opened': ApplicationOpened;
750
810
  'Application Backgrounded': ApplicationBackgrounded;
751
811
  'Application Updated': ApplicationUpdated;
812
+ 'Preferences Stated': PreferencesStated;
813
+ 'Offer Claimed': OfferClaimed;
814
+ 'Sign-up Relayed': SignUpRelayed;
752
815
  }
753
816
 
754
817
  /** Which producers the registry permits for each event. Emitting from another is refused. */
package/types/hp.d.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  CallOptions,
5
5
  RecommendationContext,
6
6
  } from './index';
7
+ import type { OnsiteFacade } from './onsite';
7
8
 
8
9
  type Methods = {
9
10
  [K in keyof AnalyticsClient as AnalyticsClient[K] extends (
@@ -40,7 +41,7 @@ interface HpTrack {
40
41
  * into the caller.
41
42
  */
42
43
  export declare const hp: {
43
- [K in Exclude<keyof Methods, 'track' | 'recommendationContext'>]: (
44
+ [K in Exclude<keyof Methods, 'track' | 'recommendationContext' | 'onsite'>]: (
44
45
  ...args: Parameters<Methods[K]>
45
46
  ) => void;
46
47
  } & {
@@ -61,4 +62,6 @@ export declare const hp: {
61
62
  * client has started, and without personalisation consent. Since 3.11.
62
63
  */
63
64
  recommendationContext(): RecommendationContext;
65
+ /** Onsite forms: `hp.onsite.open(slot)` opens a form by hand; `debug()` says why each did or did not show. */
66
+ onsite: OnsiteFacade;
64
67
  };
package/types/index.d.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  NewsletterUnsubscribed,
23
23
  PaymentInfoEntered,
24
24
  PersonalisationExposed,
25
+ PreferencesStated,
25
26
  ProductAdded,
26
27
  ProductAddedToWishlist,
27
28
  ProductClicked,
@@ -335,6 +336,11 @@ export interface AnalyticsClient {
335
336
  newsletterSubscribed: Emit<NewsletterSubscribed>;
336
337
  /** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
337
338
  newsletterUnsubscribed: Emit<NewsletterUnsubscribed>;
339
+ /**
340
+ * Answers to an approved preference question (spec 1.18). Answers sent with an onsite sign-up are
341
+ * the gateway's to report, so never send those.
342
+ */
343
+ preferencesStated: Emit<PreferencesStated>;
338
344
  /** An alert request for one product: back in stock, a price drop or a launch. */
339
345
  backInStockSubscribed: Emit<BackInStockSubscribed>;
340
346
  productReviewed: Emit<ProductReviewed>;
@@ -436,6 +442,10 @@ export interface ServingBody {
436
442
  * computes it from the settings. `default` is no decision and is not put in force. Since 3.16.
437
443
  */
438
444
  variant?: 'bandit' | 'default';
445
+ /** The A/B test the arm was assigned in, when the slot runs one. Since 3.23. */
446
+ experiment?: string;
447
+ /** The test's split token, sent back with the pin. Since 3.23. */
448
+ split?: string;
439
449
  }>;
440
450
  recommendation_request_id?: string;
441
451
  }
@@ -0,0 +1,58 @@
1
+ /** A question the form's settings publish, as the preview renders it. */
2
+ export interface PreviewQuestion {
3
+ key: string;
4
+ type?: 'one' | 'many' | 'date' | 'number';
5
+ label?: string;
6
+ /** A choice's options. */
7
+ options?: Array<{ value: string; label?: string }>;
8
+ /** A date's or a number's format, as the settings publish it. */
9
+ format?: {
10
+ kind?: 'birthday' | 'date';
11
+ year?: 'none' | 'optional' | 'required';
12
+ min?: number;
13
+ max?: number;
14
+ decimals?: boolean;
15
+ unit?: string;
16
+ };
17
+ }
18
+
19
+ export interface PreviewOptions {
20
+ /** The step to show; the first when left out or unknown. */
21
+ step?: string;
22
+ /** The small-screen layout (`mobile`) or the large one, whatever the host's own width. */
23
+ device?: 'desktop' | 'mobile';
24
+ /** The form's consent text; a sample sentence when left out. */
25
+ consentText?: string;
26
+ /** The consent text a step taking a phone number shows; a sample sentence when left out. */
27
+ smsConsentText?: string;
28
+ /**
29
+ * The form's language (the settings' `locale`, e.g. `en-GB`): a birthday's field order and month
30
+ * names, and a phone field's first country. The page's `<html lang>` when left out.
31
+ */
32
+ locale?: string;
33
+ questions?: PreviewQuestion[];
34
+ prizes?: Array<{ key: string; label: string }>;
35
+ /** The basket a progress block is drawn against; 32 when left out. */
36
+ basketValue?: number;
37
+ }
38
+
39
+ export interface OnsitePreview {
40
+ /** Re-render, on the step shown unless `options.step` names another. */
41
+ update(creative: unknown, options?: PreviewOptions): void;
42
+ destroy(): void;
43
+ /** The step on screen, or null when the creative could not be read. */
44
+ step(): string | null;
45
+ }
46
+
47
+ /**
48
+ * Render a creative with the shoppers' renderer inside `host`, for the console: no request, no
49
+ * event, no counter; its buttons only move between steps.
50
+ */
51
+ export declare function mountPreview(
52
+ host: HTMLElement,
53
+ creative: unknown,
54
+ options?: PreviewOptions,
55
+ ): OnsitePreview;
56
+
57
+ /** The countries a phone field's country code picker offers, by ISO code, with their calling codes. */
58
+ export declare const CALLING_CODES: Readonly<Record<string, string>>;
@@ -0,0 +1,11 @@
1
+ export interface OnsiteWheel {
2
+ /** Turn to the prize at `index`, the one the server drew. Instant under reduced motion. */
3
+ spinTo(index: number): Promise<void>;
4
+ remove(): void;
5
+ }
6
+
7
+ /** Draw the prize wheel into `container`. Null for fewer than two prizes. */
8
+ export declare function mountWheel(
9
+ container: Element,
10
+ prizes: Array<{ key: string; label: string }>,
11
+ ): OnsiteWheel | null;