@thg-agentic/hyper-personalisation-web 3.24.1 → 3.26.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 +100 -25
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +1 -1
- package/dist/onsite-audiences.min.js +1 -0
- package/dist/onsite-render.min.js +2 -2
- package/dist/onsite-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +4 -3
- package/src/bootstrap.js +114 -49
- package/src/consent-gate.js +8 -5
- package/src/context.js +2 -1
- package/src/device-storage.js +4 -4
- package/src/index.js +142 -10
- package/src/onsite/api.js +22 -5
- package/src/onsite/audiences-loader.js +6 -0
- package/src/onsite/counters.js +2 -2
- package/src/onsite/index.js +52 -1
- package/src/onsite/render.js +49 -18
- package/src/onsite/rules.js +20 -5
- package/src/onsite/visit-audiences.js +94 -0
- package/src/onsite/wheel.js +61 -13
- package/src/settings.js +30 -2
- package/src/shape.js +4 -0
- package/src/social-proof.js +5 -1
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +8 -2
- package/src/transport.js +22 -2
- package/src/version.js +1 -1
- package/src/visit-campaign.js +168 -0
- package/types/bootstrap.d.ts +10 -2
- package/types/events.d.ts +30 -2
- package/types/hp.d.ts +3 -2
- package/types/index.d.ts +10 -3
- package/types/onsite.d.ts +9 -0
package/src/consent-gate.js
CHANGED
|
@@ -98,7 +98,8 @@ function previousFromCookie(name) {
|
|
|
98
98
|
* @param {boolean} [options.debug]
|
|
99
99
|
* @param {{ read: () => any[], write: (items: any[]) => void }} [options.store]
|
|
100
100
|
* Carry held items across pages (`holdAcrossPages`). Read on start, so a grant on the next page
|
|
101
|
-
* releases what the previous one held; cleared on a grant or a
|
|
101
|
+
* releases what the previous one held; written when the page is hidden, cleared on a grant or a
|
|
102
|
+
* refusal.
|
|
102
103
|
*/
|
|
103
104
|
export function createConsentGate(options) {
|
|
104
105
|
const {
|
|
@@ -240,9 +241,12 @@ export function createConsentGate(options) {
|
|
|
240
241
|
*/
|
|
241
242
|
function onHidden(event) {
|
|
242
243
|
if (!awaitingAnswer()) return;
|
|
243
|
-
// With a store, the stored copy is what the next page answers for:
|
|
244
|
-
// discards nothing.
|
|
245
|
-
if (store)
|
|
244
|
+
// With a store, the stored copy is what the next page answers for: written here, once, rather
|
|
245
|
+
// than on every held event, and the end of this page discards nothing.
|
|
246
|
+
if (store) {
|
|
247
|
+
store.write(held);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
246
250
|
if (event?.type !== 'visibilitychange' && !event?.persisted) discardHeld();
|
|
247
251
|
}
|
|
248
252
|
|
|
@@ -297,7 +301,6 @@ export function createConsentGate(options) {
|
|
|
297
301
|
return false;
|
|
298
302
|
}
|
|
299
303
|
held.push(item);
|
|
300
|
-
store?.write(held);
|
|
301
304
|
return true;
|
|
302
305
|
},
|
|
303
306
|
/** Items unload would lose; the caller's own unload path flushes after this. */
|
package/src/context.js
CHANGED
|
@@ -344,7 +344,8 @@ export const CONTEXT_KEYS = [
|
|
|
344
344
|
|
|
345
345
|
export function sourceContext(cfg) {
|
|
346
346
|
return {
|
|
347
|
-
|
|
347
|
+
// None known (BUILT_IN settings): the ingest stamps the key's.
|
|
348
|
+
...(cfg.propertyId == null ? {} : { property_id: String(cfg.propertyId) }),
|
|
348
349
|
channel: cfg.channel ?? null,
|
|
349
350
|
market: cfg.market ?? null,
|
|
350
351
|
environment: cfg.environment ?? null,
|
package/src/device-storage.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Two device writes beyond the SDK's own buffers.
|
|
3
3
|
*
|
|
4
4
|
* held store events the consent gate holds for an unanswered CMP, in sessionStorage per
|
|
5
|
-
*
|
|
6
|
-
* them (`holdAcrossPages`, 3.5). They leave only on a
|
|
7
|
-
* comes; a refusal deletes them.
|
|
5
|
+
* write key, so a navigation before the answer (a sign-in redirect) does not lose
|
|
6
|
+
* them (`holdAcrossPages`, 3.5). They leave only on a
|
|
7
|
+
* grant, on whichever page it comes; a refusal deletes them.
|
|
8
8
|
* device cookie Hyper-Personalisation's device id, for a source whose settings say `hp_cookie`
|
|
9
9
|
* (3.6). Under strict consent it is written only once analytics is granted.
|
|
10
10
|
*/
|
|
@@ -33,7 +33,7 @@ const local = () => {
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* @param {string} key A
|
|
36
|
+
* @param {string} key A write-key-scoped key (storageKey(storageName('held'), writeKey)).
|
|
37
37
|
* @param {() => number} [now]
|
|
38
38
|
*/
|
|
39
39
|
export function createHeldStore(key, now = () => Date.now()) {
|
package/src/index.js
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
getCookie,
|
|
51
51
|
localeTag,
|
|
52
52
|
perRequest,
|
|
53
|
+
readCampaign,
|
|
53
54
|
scrubHref,
|
|
54
55
|
} from './context.js';
|
|
55
56
|
import { createDecisionMemory, servedDecisions } from './decisions.js';
|
|
@@ -82,6 +83,7 @@ import {
|
|
|
82
83
|
PAGE_TYPES,
|
|
83
84
|
pruneProperties,
|
|
84
85
|
realId,
|
|
86
|
+
tokenOf,
|
|
85
87
|
uuid,
|
|
86
88
|
} from './shape.js';
|
|
87
89
|
import {
|
|
@@ -96,6 +98,12 @@ import {
|
|
|
96
98
|
byteLength as utf8Bytes,
|
|
97
99
|
} from './transport.js';
|
|
98
100
|
import { LIB, VERSION } from './version.js';
|
|
101
|
+
import {
|
|
102
|
+
clearVisitCampaign,
|
|
103
|
+
keepVisitCampaign,
|
|
104
|
+
readVisitCampaign,
|
|
105
|
+
visitCampaign,
|
|
106
|
+
} from './visit-campaign.js';
|
|
99
107
|
|
|
100
108
|
/** The surfaces a `Personalisation Exposed` names, as the public API's exposures do. */
|
|
101
109
|
const EXPOSURE_SURFACES = new Set([
|
|
@@ -115,6 +123,10 @@ export { emailSha256, getCookie };
|
|
|
115
123
|
|
|
116
124
|
/** How a call made before the SDK started is replayed with the messageId and time it was made at. */
|
|
117
125
|
const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
|
|
126
|
+
/** How bootstrap tells the client its API could not be reached. Not public API. */
|
|
127
|
+
const UNREACHABLE = Symbol.for(
|
|
128
|
+
'@thg-agentic/hyper-personalisation-web.unreachable',
|
|
129
|
+
);
|
|
118
130
|
/** How ./onsite reads what it needs from a client it was handed. Not public API. */
|
|
119
131
|
const ONSITE_ACCESS = Symbol.for(
|
|
120
132
|
'@thg-agentic/hyper-personalisation-web.onsite-access',
|
|
@@ -309,14 +321,65 @@ function contextOverrides(options) {
|
|
|
309
321
|
return options;
|
|
310
322
|
}
|
|
311
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Before 3.25 the retry buffer and the held store were per property. What a 3.24 page kept there
|
|
326
|
+
* moves to this write key's, oldest first, and the old key goes: the batches keep their messageIds
|
|
327
|
+
* and are replayed as any other, under the same consent rules.
|
|
328
|
+
*/
|
|
329
|
+
function adoptPropertyScopedBuffers(propertyId, writeKey) {
|
|
330
|
+
if (propertyId == null || String(propertyId) === writeKey) return;
|
|
331
|
+
const moves = [
|
|
332
|
+
[
|
|
333
|
+
'localStorage',
|
|
334
|
+
'retry',
|
|
335
|
+
(old, current) => [
|
|
336
|
+
...(Array.isArray(old) ? old : []),
|
|
337
|
+
...(Array.isArray(current) ? current : []),
|
|
338
|
+
],
|
|
339
|
+
],
|
|
340
|
+
[
|
|
341
|
+
'sessionStorage',
|
|
342
|
+
'held',
|
|
343
|
+
(old, current) =>
|
|
344
|
+
current
|
|
345
|
+
? {
|
|
346
|
+
...current,
|
|
347
|
+
items: [...(old.items ?? []), ...(current.items ?? [])],
|
|
348
|
+
}
|
|
349
|
+
: old,
|
|
350
|
+
],
|
|
351
|
+
];
|
|
352
|
+
for (const [store, name, merge] of moves) {
|
|
353
|
+
try {
|
|
354
|
+
const storage = globalThis[store];
|
|
355
|
+
const from = storageKey(storageName(name), propertyId);
|
|
356
|
+
const raw = storage?.getItem(from);
|
|
357
|
+
if (!raw) continue;
|
|
358
|
+
const to = storageKey(storageName(name), writeKey);
|
|
359
|
+
const current = storage.getItem(to);
|
|
360
|
+
storage.setItem(
|
|
361
|
+
to,
|
|
362
|
+
JSON.stringify(
|
|
363
|
+
merge(JSON.parse(raw), current ? JSON.parse(current) : null),
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
storage.removeItem(from);
|
|
367
|
+
} catch {
|
|
368
|
+
/* unreadable or full: left where it was */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
312
373
|
export function createAnalytics(options) {
|
|
313
374
|
const apiHost = resolveApiHost(options?.apiHost);
|
|
314
375
|
if (!apiHost)
|
|
315
376
|
throw new Error(
|
|
316
377
|
'createAnalytics: apiHost, when given, must be a host or an http(s) origin',
|
|
317
378
|
);
|
|
318
|
-
if (!options?.propertyId)
|
|
319
|
-
throw new Error(
|
|
379
|
+
if (options?.propertyId !== null && !options?.propertyId)
|
|
380
|
+
throw new Error(
|
|
381
|
+
'createAnalytics: propertyId is required (null: the ingest stamps the key’s)',
|
|
382
|
+
);
|
|
320
383
|
|
|
321
384
|
const cfg = {
|
|
322
385
|
libraryName: LIB,
|
|
@@ -341,10 +404,12 @@ export function createAnalytics(options) {
|
|
|
341
404
|
cfg.getConsent = () => (gate ? gate.stamp() : hostConsent());
|
|
342
405
|
const granted = () => !strict || gate?.status === 'granted';
|
|
343
406
|
|
|
344
|
-
|
|
407
|
+
adoptPropertyScopedBuffers(cfg.propertyId, cfg.writeKey);
|
|
408
|
+
// Opt-in (3.5): events held for an unanswered CMP survive a navigation, in sessionStorage. Per
|
|
409
|
+
// write key, as the retry buffer: what they are sent with.
|
|
345
410
|
const heldStore =
|
|
346
411
|
strict && cfg.holdAcrossPages
|
|
347
|
-
? createHeldStore(storageKey(storageName('held'), cfg.
|
|
412
|
+
? createHeldStore(storageKey(storageName('held'), cfg.writeKey))
|
|
348
413
|
: null;
|
|
349
414
|
const carried = heldStore?.read() ?? { items: [], anonymousId: null };
|
|
350
415
|
|
|
@@ -369,6 +434,28 @@ export function createAnalytics(options) {
|
|
|
369
434
|
storageName('preview'),
|
|
370
435
|
cfg.consentCookieDomain ?? null,
|
|
371
436
|
);
|
|
437
|
+
/** The session kept on the device (the host's, or the SDK's cookie); null while in memory only. */
|
|
438
|
+
const keptSession = () =>
|
|
439
|
+
realId(perRequest(hostSession, null)) ?? getCookie(storageName('session'));
|
|
440
|
+
/**
|
|
441
|
+
* The tags the page loaded with, read now so a later `replaceState` cannot take them away, and
|
|
442
|
+
* the visit's campaign kept from them when the visit has none (visit-campaign.js).
|
|
443
|
+
*/
|
|
444
|
+
const landingCampaign = visitCampaign(
|
|
445
|
+
typeof location === 'undefined' ? null : readCampaign(location.search),
|
|
446
|
+
);
|
|
447
|
+
/**
|
|
448
|
+
* A page or an event of the visit, as it is collected whatever consent will say of sending it: the
|
|
449
|
+
* visit's campaign lasts as its session does. A trader's preview is not a shopper's visit: it
|
|
450
|
+
* reads its own URL's tags and keeps none.
|
|
451
|
+
*/
|
|
452
|
+
const keepCampaign = (landing = null) => {
|
|
453
|
+
if (!previewing)
|
|
454
|
+
keepVisitCampaign(cfg.propertyId, landing, {
|
|
455
|
+
sessionId: keptSession(),
|
|
456
|
+
keepSession: granted(),
|
|
457
|
+
});
|
|
458
|
+
};
|
|
372
459
|
|
|
373
460
|
/** Delivery problems, logged with `debug`. */
|
|
374
461
|
const reportError = (message, err) =>
|
|
@@ -379,9 +466,9 @@ export function createAnalytics(options) {
|
|
|
379
466
|
new Transport({
|
|
380
467
|
apiHost: cfg.apiHost,
|
|
381
468
|
writeKey: cfg.writeKey,
|
|
382
|
-
// Per
|
|
383
|
-
//
|
|
384
|
-
storageKey: storageKey(storageName('retry'), cfg.
|
|
469
|
+
// Per write key, which a replay is sent with: one origin can serve several tenants, and a page
|
|
470
|
+
// started without its property (BUILT_IN settings) is replayed by the next one, which has it.
|
|
471
|
+
storageKey: storageKey(storageName('retry'), cfg.writeKey),
|
|
385
472
|
// Under strict consent, persisting is a device write and replaying is a send: both wait for a
|
|
386
473
|
// grant (see onStatus below, which replays on a grant and clears on a refusal).
|
|
387
474
|
mayPersist: strict ? () => gate?.status === 'granted' : undefined,
|
|
@@ -432,6 +519,12 @@ export function createAnalytics(options) {
|
|
|
432
519
|
let identifiedUserId = null;
|
|
433
520
|
/** The user bootstrap's `identity` option named, only to warn when the host names another form. */
|
|
434
521
|
let optionsUserId = null;
|
|
522
|
+
/**
|
|
523
|
+
* The signed-in user's identity token (ADR 0067), the one copy of it: from the `identityToken`
|
|
524
|
+
* option for the user `getUserId` named, then whatever `identify` passes for its user. Another
|
|
525
|
+
* user, `reset()` or a sign-out clears it, so nobody else's token outlives their sign-in.
|
|
526
|
+
*/
|
|
527
|
+
let identityToken = userId ? tokenOf(cfg.identityToken) : null;
|
|
435
528
|
|
|
436
529
|
/** The call being replayed: its first event keeps the messageId and time it was made with. */
|
|
437
530
|
let replaying = null;
|
|
@@ -464,6 +557,7 @@ export function createAnalytics(options) {
|
|
|
464
557
|
/** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
|
|
465
558
|
function emit(partial, ctxOverrides, force = false) {
|
|
466
559
|
noteBasket(partial);
|
|
560
|
+
keepCampaign();
|
|
467
561
|
try {
|
|
468
562
|
return compose(partial, ctxOverrides, force);
|
|
469
563
|
} catch (err) {
|
|
@@ -608,6 +702,7 @@ export function createAnalytics(options) {
|
|
|
608
702
|
exposedTokens.clear();
|
|
609
703
|
device?.forget();
|
|
610
704
|
forgetKeptVisit(true);
|
|
705
|
+
clearVisitCampaign();
|
|
611
706
|
}
|
|
612
707
|
|
|
613
708
|
function deliver(event, force) {
|
|
@@ -644,6 +739,8 @@ export function createAnalytics(options) {
|
|
|
644
739
|
};
|
|
645
740
|
};
|
|
646
741
|
|
|
742
|
+
// Before the gate starts, so a withdrawal it finds on this page clears the landing tags too.
|
|
743
|
+
keepCampaign(landingCampaign);
|
|
647
744
|
if (strict) {
|
|
648
745
|
gate = createConsentGate({
|
|
649
746
|
adapter: cfg.consent,
|
|
@@ -701,7 +798,10 @@ export function createAnalytics(options) {
|
|
|
701
798
|
if (state?.personalisation !== true)
|
|
702
799
|
forgetKeptVisit(previous?.personalisation === true);
|
|
703
800
|
// Events collected under the grant are not sent after it is withdrawn: only the withdrawal.
|
|
704
|
-
if (withdrawal)
|
|
801
|
+
if (withdrawal) {
|
|
802
|
+
transport.discardQueued?.();
|
|
803
|
+
clearVisitCampaign();
|
|
804
|
+
}
|
|
705
805
|
emit(
|
|
706
806
|
{
|
|
707
807
|
type: 'track',
|
|
@@ -715,6 +815,8 @@ export function createAnalytics(options) {
|
|
|
715
815
|
},
|
|
716
816
|
});
|
|
717
817
|
gate.start();
|
|
818
|
+
// Only what survived the gate: a visitor already granted keeps the session id with it.
|
|
819
|
+
keepCampaign();
|
|
718
820
|
}
|
|
719
821
|
|
|
720
822
|
/** The property's holdout split from the settings, as a serving response would state it. */
|
|
@@ -802,6 +904,7 @@ export function createAnalytics(options) {
|
|
|
802
904
|
userId = null;
|
|
803
905
|
identifiedUserId = null;
|
|
804
906
|
optionsUserId = null;
|
|
907
|
+
identityToken = null;
|
|
805
908
|
return event;
|
|
806
909
|
}
|
|
807
910
|
|
|
@@ -846,8 +949,17 @@ export function createAnalytics(options) {
|
|
|
846
949
|
identify(traits, ctx, source) {
|
|
847
950
|
const { userId: id, ...rest } =
|
|
848
951
|
traits && typeof traits === 'object' ? traits : {};
|
|
952
|
+
// The token the site's server signed for this user (ADR 0067): without it the identity graph
|
|
953
|
+
// records the sign-in and never joins this device to the user's others.
|
|
954
|
+
const passed = tokenOf(contextOverrides(ctx)?.identity_token);
|
|
849
955
|
if (id !== undefined) {
|
|
850
956
|
const next = realId(id);
|
|
957
|
+
// Another user's sign-in brings its own token or none; the same user's keeps the last.
|
|
958
|
+
identityToken = !next
|
|
959
|
+
? null
|
|
960
|
+
: next === userId
|
|
961
|
+
? (passed ?? identityToken)
|
|
962
|
+
: passed;
|
|
851
963
|
if (source === IDENTITY_OPTION) {
|
|
852
964
|
optionsUserId = next;
|
|
853
965
|
} else if (identifiedUserId && next && next !== identifiedUserId) {
|
|
@@ -867,10 +979,12 @@ export function createAnalytics(options) {
|
|
|
867
979
|
}
|
|
868
980
|
userId = next;
|
|
869
981
|
if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
|
|
870
|
-
}
|
|
982
|
+
} else if (passed && userId) identityToken = passed;
|
|
983
|
+
// Only the current user's token is sent: one passed with nobody to keep it for goes nowhere.
|
|
984
|
+
const { identity_token: _, ...context } = contextOverrides(ctx) ?? {};
|
|
871
985
|
return emit(
|
|
872
986
|
{ type: 'identify', traits: identifyTraits(rest, cfg.debug) },
|
|
873
|
-
|
|
987
|
+
identityToken ? { ...context, identity_token: identityToken } : context,
|
|
874
988
|
);
|
|
875
989
|
},
|
|
876
990
|
|
|
@@ -884,6 +998,7 @@ export function createAnalytics(options) {
|
|
|
884
998
|
userId = null;
|
|
885
999
|
identifiedUserId = null;
|
|
886
1000
|
optionsUserId = null;
|
|
1001
|
+
identityToken = null;
|
|
887
1002
|
writeLastPageMessageId('');
|
|
888
1003
|
},
|
|
889
1004
|
|
|
@@ -1063,6 +1178,8 @@ export function createAnalytics(options) {
|
|
|
1063
1178
|
preview: previewing,
|
|
1064
1179
|
anonymousId: () => realId(perRequest(cfg.getAnonymousId, null)),
|
|
1065
1180
|
sessionId: () => realId(perRequest(cfg.getSessionId, null)),
|
|
1181
|
+
// Only where it is used: a reminder's code is answered with personalisation granted.
|
|
1182
|
+
identityToken: () => (personalisationGranted() ? identityToken : null),
|
|
1066
1183
|
analytics: granted,
|
|
1067
1184
|
personalisation: personalisationGranted,
|
|
1068
1185
|
consent: () => cfg.getConsent(),
|
|
@@ -1072,6 +1189,11 @@ export function createAnalytics(options) {
|
|
|
1072
1189
|
? null
|
|
1073
1190
|
: scrubHref(location.href, cfg),
|
|
1074
1191
|
locale: () => localeTag(perRequest(cfg.getLocale, cfg.locale)),
|
|
1192
|
+
/** The visit's campaign for "arrived from"; a preview's is its own URL's. */
|
|
1193
|
+
campaign: () =>
|
|
1194
|
+
previewing
|
|
1195
|
+
? landingCampaign
|
|
1196
|
+
: readVisitCampaign(cfg.propertyId, { sessionId: keptSession() }),
|
|
1075
1197
|
/**
|
|
1076
1198
|
* A basket bar's progress: the basket this page reported, else the last one this visit
|
|
1077
1199
|
* kept (under personalisation consent), else null.
|
|
@@ -1080,6 +1202,12 @@ export function createAnalytics(options) {
|
|
|
1080
1202
|
const value = getSessionSignals().basketValue;
|
|
1081
1203
|
return pageBasket ?? (Number.isFinite(value) ? value : null);
|
|
1082
1204
|
},
|
|
1205
|
+
/** This visit's session signals, kept only under personalisation consent; else null. */
|
|
1206
|
+
sessionSignals: () => {
|
|
1207
|
+
if (!personalisationGranted()) return null;
|
|
1208
|
+
scopeVisit();
|
|
1209
|
+
return getSessionSignals();
|
|
1210
|
+
},
|
|
1083
1211
|
/** Calls `listener` whenever the page reports its basket; answers how to stop. */
|
|
1084
1212
|
onBasket: (listener) => {
|
|
1085
1213
|
basketListeners.add(listener);
|
|
@@ -1098,6 +1226,10 @@ export function createAnalytics(options) {
|
|
|
1098
1226
|
},
|
|
1099
1227
|
};
|
|
1100
1228
|
},
|
|
1229
|
+
/** bootstrap: the settings could not be fetched, so the ingest on the same host is taken as down. */
|
|
1230
|
+
[UNREACHABLE]() {
|
|
1231
|
+
transport.markFailing?.();
|
|
1232
|
+
},
|
|
1101
1233
|
/** `hp` and bootstrap replay a call made before the start through this. Not public API. */
|
|
1102
1234
|
[REPLAY](method, args, made) {
|
|
1103
1235
|
if (typeof this[method] !== 'function') return;
|
package/src/onsite/api.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The calls a form makes, each authenticated with the page's publishable key and never with a
|
|
3
3
|
* cookie: `POST /v1/decisions` and `POST /v1/offers/claim` on the public API (with `hp-consent`, as
|
|
4
|
-
* every serving request), `GET /v1/
|
|
5
|
-
* `POST /v1/capture/answers` on the ingest
|
|
4
|
+
* every serving request), `GET /v1/audiences/datafile` there for a form limited to audiences,
|
|
5
|
+
* `GET /v1/capture/challenge`, `POST /v1/capture` and `POST /v1/capture/answers` on the ingest
|
|
6
|
+
* gateway.
|
|
6
7
|
* Every call resolves, to `{ status, body }` (status 0 when the network failed).
|
|
7
8
|
*/
|
|
8
9
|
|
|
@@ -16,6 +17,8 @@ const challengePath = (path) =>
|
|
|
16
17
|
|
|
17
18
|
/** How long one claim request may take before it counts as failed. */
|
|
18
19
|
const CLAIM_TIMEOUT_MS = 8000;
|
|
20
|
+
/** How long a limited form waits for the audience datafile before it is decided without a claim. */
|
|
21
|
+
const DATAFILE_TIMEOUT_MS = 1500;
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* @param {object} access What the client exposes to ./onsite (index.js, ONSITE_ACCESS).
|
|
@@ -57,8 +60,13 @@ export function createApi(access, options = {}) {
|
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
return {
|
|
60
|
-
/**
|
|
61
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The decision for one slot, as the page's other serving calls ask for it, with its pin and
|
|
65
|
+
* what the browser knows of the visit: for a form limited to audiences, `visitAudiences`, the
|
|
66
|
+
* session audiences it meets; for a form that names a campaign, `visitCampaign`, the campaign
|
|
67
|
+
* the visit arrived from. The server holds either form back unless its rules hold.
|
|
68
|
+
*/
|
|
69
|
+
decide: (slot, pin, { visitAudiences, visitCampaign } = {}) =>
|
|
62
70
|
call(`${serving}/v1/decisions`, {
|
|
63
71
|
method: 'POST',
|
|
64
72
|
consent: true,
|
|
@@ -73,9 +81,18 @@ export function createApi(access, options = {}) {
|
|
|
73
81
|
],
|
|
74
82
|
identifier_value: access.anonymousId() ?? undefined,
|
|
75
83
|
session_id: access.sessionId() ?? undefined,
|
|
84
|
+
// What a promo reminder's code is answered for under a publishable key (ADR 0067).
|
|
85
|
+
identity_token: access.identityToken?.() ?? undefined,
|
|
76
86
|
...(access.preview ? { preview: access.preview } : {}),
|
|
87
|
+
...(visitAudiences ? { visit_audiences: visitAudiences } : {}),
|
|
88
|
+
...(visitCampaign ? { visit_campaign: visitCampaign } : {}),
|
|
77
89
|
},
|
|
78
90
|
}),
|
|
91
|
+
/** The property's audience definitions: the same for everybody, so no consent header. */
|
|
92
|
+
datafile: () =>
|
|
93
|
+
call(`${serving}/v1/audiences/datafile`, {
|
|
94
|
+
timeoutMs: DATAFILE_TIMEOUT_MS,
|
|
95
|
+
}),
|
|
79
96
|
challenge: () => call(`${gateway}${challengePath(options.challenge)}`),
|
|
80
97
|
capture: (body) => call(`${gateway}/v1/capture`, { method: 'POST', body }),
|
|
81
98
|
// Answers given after the email step, with its address and receipt.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-rule evaluator, only for a form limited to audiences: a bundler splits this import
|
|
3
|
+
* out of ./onsite, and the CDN build swaps this module for cdn/audiences-loader.js, which loads
|
|
4
|
+
* dist/onsite-audiences.min.js.
|
|
5
|
+
*/
|
|
6
|
+
export const loadAudiences = () => import('./visit-audiences.js');
|
package/src/onsite/counters.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* What the caps count, per slot, as opaque numbers: shows this session (sessionStorage), and shows
|
|
3
3
|
* ever, when it was last closed and whether it was submitted (localStorage), plus one subscribed
|
|
4
4
|
* flag, whether a teaser's tab is out, when a covering form last showed and the version an A/B test
|
|
5
|
-
* pinned for the visit (sessionStorage), and the
|
|
6
|
-
*
|
|
5
|
+
* pinned for the visit (sessionStorage), and the last code this browser claimed, for a promo
|
|
6
|
+
* reminder. Never an address, a number or an answer.
|
|
7
7
|
* Every access is wrapped: without storage the caps hold for the page view only.
|
|
8
8
|
*
|
|
9
9
|
* The counts follow what the shopper did (saw, closed, signed up) and serve only the caps: UI
|
package/src/onsite/index.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { loadAnswers } from './answers-loader.js';
|
|
21
21
|
import { createApi } from './api.js';
|
|
22
|
+
import { loadAudiences } from './audiences-loader.js';
|
|
22
23
|
import { createCounters } from './counters.js';
|
|
23
24
|
import { loadRender } from './render-loader.js';
|
|
24
25
|
import {
|
|
@@ -63,6 +64,8 @@ const INERT = {
|
|
|
63
64
|
code: () => null,
|
|
64
65
|
};
|
|
65
66
|
|
|
67
|
+
/** The longest a limited form waits for the evaluator and the datafile before it is decided without. */
|
|
68
|
+
const CLAIMS_MS = 1500;
|
|
66
69
|
/** How long a fired trigger waits for others, so two forms entering together show the higher priority. */
|
|
67
70
|
const FIRE_WINDOW_MS = 50;
|
|
68
71
|
/** The longest a form waits for its first step's pictures before it opens without them. */
|
|
@@ -103,6 +106,13 @@ export function startOnsite(client, options = {}) {
|
|
|
103
106
|
const counters = access.preview
|
|
104
107
|
? INERT
|
|
105
108
|
: createCounters(access.storageKey, access.personalisation);
|
|
109
|
+
/**
|
|
110
|
+
* The campaign this visit arrived from, the landing page's tags (GA4's session source, and the
|
|
111
|
+
* warehouse's): kept for the visit from the first page that has them. A preview reads this page's
|
|
112
|
+
* own, so a trader's test link shows the form its tags would.
|
|
113
|
+
*/
|
|
114
|
+
const campaign = () => access.campaign?.() ?? null;
|
|
115
|
+
|
|
106
116
|
const mounted = new Map();
|
|
107
117
|
const suppressed = () => window[SUPPRESSED]?.size > 0;
|
|
108
118
|
|
|
@@ -131,6 +141,18 @@ export function startOnsite(client, options = {}) {
|
|
|
131
141
|
reasons: ['not a form this SDK can show'],
|
|
132
142
|
});
|
|
133
143
|
}
|
|
144
|
+
/** The session-rule evaluator, for a limited form; null when its chunk cannot be loaded. */
|
|
145
|
+
const audiencesChunk = () =>
|
|
146
|
+
loadAudiences().catch((err) => {
|
|
147
|
+
console.warn('[hp] onsite audiences not loaded:', err?.message ?? err);
|
|
148
|
+
return null;
|
|
149
|
+
});
|
|
150
|
+
// Warmed with the datafile now, so a limited form that fires waits for neither.
|
|
151
|
+
if (
|
|
152
|
+
access.personalisation() &&
|
|
153
|
+
forms.some((form) => form.audiences.length > 0)
|
|
154
|
+
)
|
|
155
|
+
audiencesChunk().then((chunk) => chunk?.prefetch(api));
|
|
134
156
|
/**
|
|
135
157
|
* The covering forms on screen or being opened, by slot (a bar and a popup the shopper asked for
|
|
136
158
|
* can both be up), the slots being opened now, and the form that spent this page view's allowance.
|
|
@@ -199,6 +221,7 @@ export function startOnsite(client, options = {}) {
|
|
|
199
221
|
const env = (form) => ({
|
|
200
222
|
path: location.pathname,
|
|
201
223
|
device,
|
|
224
|
+
campaign: campaign(),
|
|
202
225
|
now: Date.now(),
|
|
203
226
|
seen: counters.seen(form.slot),
|
|
204
227
|
subscribed: counters.subscribed() || options.subscribed?.() === true,
|
|
@@ -285,7 +308,35 @@ export function startOnsite(client, options = {}) {
|
|
|
285
308
|
console.warn('[hp] onsite renderer not loaded:', err?.message ?? err);
|
|
286
309
|
return null;
|
|
287
310
|
});
|
|
288
|
-
|
|
311
|
+
// The server decides a limited form's audiences; the browser tells it which session rules hold,
|
|
312
|
+
// and which the page gave. Within CLAIMS_MS, else only the page's: the server decides from what
|
|
313
|
+
// it holds.
|
|
314
|
+
// The evaluator orders the page's own with the visit's (targets and what they require first);
|
|
315
|
+
// a fallback sends the page's alone, the form's targets first and capped at the server's 100.
|
|
316
|
+
const page = sessionAudiences() ?? [];
|
|
317
|
+
const given = [...new Set([...form.audiences, ...page])]
|
|
318
|
+
.filter((slug) => page.includes(slug))
|
|
319
|
+
.slice(0, 100);
|
|
320
|
+
const claimed =
|
|
321
|
+
form.audiences.length > 0 && !access.preview && access.personalisation()
|
|
322
|
+
? await Promise.race([
|
|
323
|
+
audiencesChunk().then((chunk) =>
|
|
324
|
+
chunk
|
|
325
|
+
? chunk.visitAudiences(
|
|
326
|
+
api,
|
|
327
|
+
access.sessionSignals,
|
|
328
|
+
form.audiences,
|
|
329
|
+
page,
|
|
330
|
+
)
|
|
331
|
+
: given,
|
|
332
|
+
),
|
|
333
|
+
new Promise((resolve) => setTimeout(resolve, CLAIMS_MS, given)),
|
|
334
|
+
])
|
|
335
|
+
: null;
|
|
336
|
+
const res = await api.decide(form.slot, counters.pin(form.slot), {
|
|
337
|
+
visitAudiences: claimed,
|
|
338
|
+
visitCampaign: form.arrived_from.length ? campaign() : null,
|
|
339
|
+
});
|
|
289
340
|
const decision =
|
|
290
341
|
res.status === 200
|
|
291
342
|
? res.body?.decisions?.find((d) => d?.slot === form.slot)
|
package/src/onsite/render.js
CHANGED
|
@@ -56,6 +56,8 @@ const COPY = {
|
|
|
56
56
|
claimed: 'This email address has already claimed its code.',
|
|
57
57
|
copied: 'Copied',
|
|
58
58
|
thanks: 'Thanks, you’re signed up',
|
|
59
|
+
retry: 'You’re signed up, but we couldn’t load your prize. Please try again.',
|
|
60
|
+
unclaimed: 'We couldn’t load your prize right now.',
|
|
59
61
|
unavailable: 'Not available right now.',
|
|
60
62
|
won: 'You won',
|
|
61
63
|
};
|
|
@@ -404,6 +406,9 @@ export function mountForm(o) {
|
|
|
404
406
|
warmed: o.warmed ?? new Set(),
|
|
405
407
|
warming: false,
|
|
406
408
|
owed: false,
|
|
409
|
+
// Whether the last claim failed (said where the code would be), and how many spins it failed.
|
|
410
|
+
unclaimed: false,
|
|
411
|
+
misses: 0,
|
|
407
412
|
submitted: false,
|
|
408
413
|
pow: null,
|
|
409
414
|
wheel: null,
|
|
@@ -651,9 +656,19 @@ export function mountForm(o) {
|
|
|
651
656
|
function fillCodes() {
|
|
652
657
|
for (const box of state.codes) {
|
|
653
658
|
box.replaceChildren();
|
|
654
|
-
if (!state.claim)
|
|
659
|
+
if (!state.claim) {
|
|
660
|
+
if (state.unclaimed) box.append(el('p', '', COPY.unclaimed));
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
655
663
|
if (!state.claim.code) {
|
|
656
|
-
|
|
664
|
+
// A code still to come only when every pool was out (`fallback`); a prize with no pool
|
|
665
|
+
// ("Try again") gives none, so it says its own words and nothing about a code.
|
|
666
|
+
const said = state.claim.held
|
|
667
|
+
? COPY.claimed
|
|
668
|
+
: state.claim.fallback === true
|
|
669
|
+
? COPY.code
|
|
670
|
+
: state.claim.prize?.label;
|
|
671
|
+
if (said) box.append(el('p', '', said));
|
|
657
672
|
continue;
|
|
658
673
|
}
|
|
659
674
|
// The prize the code is for, beside it: "You won!" alone does not say what.
|
|
@@ -1206,6 +1221,7 @@ export function mountForm(o) {
|
|
|
1206
1221
|
if (channel === 'email')
|
|
1207
1222
|
state.signedUp = { email: value, email_receipt: state.receipt };
|
|
1208
1223
|
state.submitted = true;
|
|
1224
|
+
// Subscribed and done with this form, whatever its claim does: a shopper is never asked twice.
|
|
1209
1225
|
counters.submitted(form.slot);
|
|
1210
1226
|
engaged('submit', step.id);
|
|
1211
1227
|
// A form with prizes claims its code now, whether or not the shopper goes on to spin, so the
|
|
@@ -1246,27 +1262,36 @@ export function mountForm(o) {
|
|
|
1246
1262
|
* The creative's step `id`, else the SDK's own plain one: a thanks with no prize (`fallback`); a
|
|
1247
1263
|
* code this browser claimed on another form, with that code (`held`); or, for an address another
|
|
1248
1264
|
* sign-up claimed with (`held`, no code), a welcome back. That code is never shown here: the
|
|
1249
|
-
* sign-up hands it to the destinations again, so the inbox has it when one carries it.
|
|
1265
|
+
* sign-up hands it to the destinations again, so the inbox has it when one carries it. Or a claim
|
|
1266
|
+
* that kept failing (`unclaimed`): the sign-up stands and the prize could not be loaded, so it
|
|
1267
|
+
* thanks the shopper and says so, as Klaviyo's and Privy's wheels do, rather than an error.
|
|
1250
1268
|
*/
|
|
1251
1269
|
function ownStep(id) {
|
|
1252
1270
|
if (creative.steps.some((s) => s.id === id)) return id;
|
|
1271
|
+
const shop = { t: 'button', label: COPY.shop, action: 'close' };
|
|
1253
1272
|
const blocks =
|
|
1254
|
-
id === '
|
|
1273
|
+
id === 'unclaimed'
|
|
1255
1274
|
? [
|
|
1256
|
-
{
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
},
|
|
1260
|
-
{ t: 'code' },
|
|
1275
|
+
{ t: 'heading', text: COPY.thanks },
|
|
1276
|
+
{ t: 'text', text: COPY.unclaimed },
|
|
1277
|
+
shop,
|
|
1261
1278
|
]
|
|
1262
|
-
:
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1279
|
+
: id === 'fallback' || state.claim?.code
|
|
1280
|
+
? [
|
|
1281
|
+
{
|
|
1282
|
+
t: 'heading',
|
|
1283
|
+
text: id === 'fallback' ? COPY.thanks : COPY.yours,
|
|
1284
|
+
},
|
|
1285
|
+
{ t: 'code' },
|
|
1286
|
+
]
|
|
1287
|
+
: [
|
|
1288
|
+
{ t: 'heading', text: COPY.held },
|
|
1289
|
+
{
|
|
1290
|
+
t: 'text',
|
|
1291
|
+
text: form.code_relayed === true ? COPY.resent : COPY.inbox,
|
|
1292
|
+
},
|
|
1293
|
+
shop,
|
|
1294
|
+
];
|
|
1270
1295
|
creative.steps.push({ id, blocks });
|
|
1271
1296
|
return id;
|
|
1272
1297
|
}
|
|
@@ -1284,6 +1309,7 @@ export function mountForm(o) {
|
|
|
1284
1309
|
function claim(retries = CLAIM_RETRY_MS) {
|
|
1285
1310
|
const receipt = state.receipt;
|
|
1286
1311
|
state.claiming ??= (async () => {
|
|
1312
|
+
state.unclaimed = false;
|
|
1287
1313
|
let res;
|
|
1288
1314
|
for (const ms of [0, ...retries]) {
|
|
1289
1315
|
if (ms) await new Promise((r) => setTimeout(r, ms));
|
|
@@ -1302,6 +1328,8 @@ export function mountForm(o) {
|
|
|
1302
1328
|
// the page; any other failure is final until a spin asks again.
|
|
1303
1329
|
if (!retries.length && res.status === 503) return claim();
|
|
1304
1330
|
settled(null);
|
|
1331
|
+
state.unclaimed = true;
|
|
1332
|
+
if (!state.closed) fillCodes();
|
|
1305
1333
|
return null;
|
|
1306
1334
|
}
|
|
1307
1335
|
// The browser that claimed the code proved it is the shopper's when it won it: a held claim
|
|
@@ -1322,9 +1350,12 @@ export function mountForm(o) {
|
|
|
1322
1350
|
busy(true);
|
|
1323
1351
|
const won = await claim();
|
|
1324
1352
|
if (state.closed) return;
|
|
1353
|
+
// The sign-up stands whatever the claim did: a failed one is offered once more, then the
|
|
1354
|
+
// shopper is thanked and told the prize could not be loaded, never left on an error.
|
|
1325
1355
|
if (!won) {
|
|
1326
1356
|
busy(false);
|
|
1327
|
-
|
|
1357
|
+
state.misses += 1;
|
|
1358
|
+
return state.misses < 2 ? say(COPY.retry) : show(ownStep('unclaimed'));
|
|
1328
1359
|
}
|
|
1329
1360
|
const fallback = won.fallback === true && !won.code;
|
|
1330
1361
|
// A code the address claimed on another form is that form's prize: the wheel lands only on
|