@thg-agentic/hyper-personalisation-web 3.25.0 → 3.27.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 +60 -9
- 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 +1 -1
- package/dist/onsite-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +4 -3
- package/src/bootstrap.js +55 -19
- package/src/index.js +93 -8
- 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/rules.js +20 -5
- package/src/onsite/visit-audiences.js +94 -0
- package/src/session/record.js +8 -2
- package/src/session/rules.js +33 -3
- package/src/session/signals.js +5 -1
- package/src/shape.js +4 -0
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +6 -0
- package/src/version.js +1 -1
- package/src/visit-campaign.js +177 -0
- package/types/bootstrap.d.ts +7 -0
- package/types/events.d.ts +30 -2
- package/types/index.d.ts +6 -0
- package/types/onsite.d.ts +9 -0
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([
|
|
@@ -426,6 +434,28 @@ export function createAnalytics(options) {
|
|
|
426
434
|
storageName('preview'),
|
|
427
435
|
cfg.consentCookieDomain ?? null,
|
|
428
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
|
+
};
|
|
429
459
|
|
|
430
460
|
/** Delivery problems, logged with `debug`. */
|
|
431
461
|
const reportError = (message, err) =>
|
|
@@ -489,6 +519,12 @@ export function createAnalytics(options) {
|
|
|
489
519
|
let identifiedUserId = null;
|
|
490
520
|
/** The user bootstrap's `identity` option named, only to warn when the host names another form. */
|
|
491
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;
|
|
492
528
|
|
|
493
529
|
/** The call being replayed: its first event keeps the messageId and time it was made with. */
|
|
494
530
|
let replaying = null;
|
|
@@ -521,6 +557,7 @@ export function createAnalytics(options) {
|
|
|
521
557
|
/** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
|
|
522
558
|
function emit(partial, ctxOverrides, force = false) {
|
|
523
559
|
noteBasket(partial);
|
|
560
|
+
keepCampaign();
|
|
524
561
|
try {
|
|
525
562
|
return compose(partial, ctxOverrides, force);
|
|
526
563
|
} catch (err) {
|
|
@@ -604,7 +641,9 @@ export function createAnalytics(options) {
|
|
|
604
641
|
}
|
|
605
642
|
|
|
606
643
|
function observe(event) {
|
|
607
|
-
|
|
644
|
+
// A page view folds in no counter, but brings the visit's campaign into the session signals,
|
|
645
|
+
// so a landing page that sends nothing else can resolve an audience on it.
|
|
646
|
+
if (event.type === 'track' || event.type === 'page') recordVisit(event);
|
|
608
647
|
// Lets a caller fold the event the warehouse will receive into session state. An observer
|
|
609
648
|
// that throws must not cost the event.
|
|
610
649
|
if (cfg.onEvent) {
|
|
@@ -629,11 +668,15 @@ export function createAnalytics(options) {
|
|
|
629
668
|
try {
|
|
630
669
|
if (personalisationGranted()) {
|
|
631
670
|
scopeVisit();
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
671
|
+
const signals = recordSessionEvent(
|
|
672
|
+
event.event,
|
|
673
|
+
event.properties,
|
|
674
|
+
// The one visit-campaign store, kept since the landing page whatever the consent then.
|
|
675
|
+
readVisitCampaign(cfg.propertyId, { sessionId: keptSession() }),
|
|
636
676
|
);
|
|
677
|
+
// A page view changes nothing a rail reads, so it does not extend what is kept for one.
|
|
678
|
+
if (event.type === 'track')
|
|
679
|
+
keepVisit(recommendationContextFrom(signals));
|
|
637
680
|
visitKept = true;
|
|
638
681
|
} else {
|
|
639
682
|
// Not allowed, or withdrawn: what was kept under the permission goes with it.
|
|
@@ -665,6 +708,7 @@ export function createAnalytics(options) {
|
|
|
665
708
|
exposedTokens.clear();
|
|
666
709
|
device?.forget();
|
|
667
710
|
forgetKeptVisit(true);
|
|
711
|
+
clearVisitCampaign();
|
|
668
712
|
}
|
|
669
713
|
|
|
670
714
|
function deliver(event, force) {
|
|
@@ -701,6 +745,8 @@ export function createAnalytics(options) {
|
|
|
701
745
|
};
|
|
702
746
|
};
|
|
703
747
|
|
|
748
|
+
// Before the gate starts, so a withdrawal it finds on this page clears the landing tags too.
|
|
749
|
+
keepCampaign(landingCampaign);
|
|
704
750
|
if (strict) {
|
|
705
751
|
gate = createConsentGate({
|
|
706
752
|
adapter: cfg.consent,
|
|
@@ -758,7 +804,10 @@ export function createAnalytics(options) {
|
|
|
758
804
|
if (state?.personalisation !== true)
|
|
759
805
|
forgetKeptVisit(previous?.personalisation === true);
|
|
760
806
|
// Events collected under the grant are not sent after it is withdrawn: only the withdrawal.
|
|
761
|
-
if (withdrawal)
|
|
807
|
+
if (withdrawal) {
|
|
808
|
+
transport.discardQueued?.();
|
|
809
|
+
clearVisitCampaign();
|
|
810
|
+
}
|
|
762
811
|
emit(
|
|
763
812
|
{
|
|
764
813
|
type: 'track',
|
|
@@ -772,6 +821,8 @@ export function createAnalytics(options) {
|
|
|
772
821
|
},
|
|
773
822
|
});
|
|
774
823
|
gate.start();
|
|
824
|
+
// Only what survived the gate: a visitor already granted keeps the session id with it.
|
|
825
|
+
keepCampaign();
|
|
775
826
|
}
|
|
776
827
|
|
|
777
828
|
/** The property's holdout split from the settings, as a serving response would state it. */
|
|
@@ -859,6 +910,7 @@ export function createAnalytics(options) {
|
|
|
859
910
|
userId = null;
|
|
860
911
|
identifiedUserId = null;
|
|
861
912
|
optionsUserId = null;
|
|
913
|
+
identityToken = null;
|
|
862
914
|
return event;
|
|
863
915
|
}
|
|
864
916
|
|
|
@@ -903,8 +955,17 @@ export function createAnalytics(options) {
|
|
|
903
955
|
identify(traits, ctx, source) {
|
|
904
956
|
const { userId: id, ...rest } =
|
|
905
957
|
traits && typeof traits === 'object' ? traits : {};
|
|
958
|
+
// The token the site's server signed for this user (ADR 0067): without it the identity graph
|
|
959
|
+
// records the sign-in and never joins this device to the user's others.
|
|
960
|
+
const passed = tokenOf(contextOverrides(ctx)?.identity_token);
|
|
906
961
|
if (id !== undefined) {
|
|
907
962
|
const next = realId(id);
|
|
963
|
+
// Another user's sign-in brings its own token or none; the same user's keeps the last.
|
|
964
|
+
identityToken = !next
|
|
965
|
+
? null
|
|
966
|
+
: next === userId
|
|
967
|
+
? (passed ?? identityToken)
|
|
968
|
+
: passed;
|
|
908
969
|
if (source === IDENTITY_OPTION) {
|
|
909
970
|
optionsUserId = next;
|
|
910
971
|
} else if (identifiedUserId && next && next !== identifiedUserId) {
|
|
@@ -924,10 +985,12 @@ export function createAnalytics(options) {
|
|
|
924
985
|
}
|
|
925
986
|
userId = next;
|
|
926
987
|
if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
|
|
927
|
-
}
|
|
988
|
+
} else if (passed && userId) identityToken = passed;
|
|
989
|
+
// Only the current user's token is sent: one passed with nobody to keep it for goes nowhere.
|
|
990
|
+
const { identity_token: _, ...context } = contextOverrides(ctx) ?? {};
|
|
928
991
|
return emit(
|
|
929
992
|
{ type: 'identify', traits: identifyTraits(rest, cfg.debug) },
|
|
930
|
-
|
|
993
|
+
identityToken ? { ...context, identity_token: identityToken } : context,
|
|
931
994
|
);
|
|
932
995
|
},
|
|
933
996
|
|
|
@@ -941,6 +1004,7 @@ export function createAnalytics(options) {
|
|
|
941
1004
|
userId = null;
|
|
942
1005
|
identifiedUserId = null;
|
|
943
1006
|
optionsUserId = null;
|
|
1007
|
+
identityToken = null;
|
|
944
1008
|
writeLastPageMessageId('');
|
|
945
1009
|
},
|
|
946
1010
|
|
|
@@ -1120,6 +1184,8 @@ export function createAnalytics(options) {
|
|
|
1120
1184
|
preview: previewing,
|
|
1121
1185
|
anonymousId: () => realId(perRequest(cfg.getAnonymousId, null)),
|
|
1122
1186
|
sessionId: () => realId(perRequest(cfg.getSessionId, null)),
|
|
1187
|
+
// Only where it is used: a reminder's code is answered with personalisation granted.
|
|
1188
|
+
identityToken: () => (personalisationGranted() ? identityToken : null),
|
|
1123
1189
|
analytics: granted,
|
|
1124
1190
|
personalisation: personalisationGranted,
|
|
1125
1191
|
consent: () => cfg.getConsent(),
|
|
@@ -1129,6 +1195,11 @@ export function createAnalytics(options) {
|
|
|
1129
1195
|
? null
|
|
1130
1196
|
: scrubHref(location.href, cfg),
|
|
1131
1197
|
locale: () => localeTag(perRequest(cfg.getLocale, cfg.locale)),
|
|
1198
|
+
/** The visit's campaign for "arrived from"; a preview's is its own URL's. */
|
|
1199
|
+
campaign: () =>
|
|
1200
|
+
previewing
|
|
1201
|
+
? landingCampaign
|
|
1202
|
+
: readVisitCampaign(cfg.propertyId, { sessionId: keptSession() }),
|
|
1132
1203
|
/**
|
|
1133
1204
|
* A basket bar's progress: the basket this page reported, else the last one this visit
|
|
1134
1205
|
* kept (under personalisation consent), else null.
|
|
@@ -1137,6 +1208,20 @@ export function createAnalytics(options) {
|
|
|
1137
1208
|
const value = getSessionSignals().basketValue;
|
|
1138
1209
|
return pageBasket ?? (Number.isFinite(value) ? value : null);
|
|
1139
1210
|
},
|
|
1211
|
+
/**
|
|
1212
|
+
* This visit's session signals, kept only under personalisation consent; else null. The
|
|
1213
|
+
* campaign is the store's as it stands, so one that has lapsed since the last event is gone.
|
|
1214
|
+
*/
|
|
1215
|
+
sessionSignals: () => {
|
|
1216
|
+
if (!personalisationGranted()) return null;
|
|
1217
|
+
scopeVisit();
|
|
1218
|
+
return {
|
|
1219
|
+
...getSessionSignals(),
|
|
1220
|
+
campaign: readVisitCampaign(cfg.propertyId, {
|
|
1221
|
+
sessionId: keptSession(),
|
|
1222
|
+
}),
|
|
1223
|
+
};
|
|
1224
|
+
},
|
|
1140
1225
|
/** Calls `listener` whenever the page reports its basket; answers how to stop. */
|
|
1141
1226
|
onBasket: (listener) => {
|
|
1142
1227
|
basketListeners.add(listener);
|
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/rules.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* counters, so the eligibility trace (`hp.onsite.debug()`) says exactly what decided.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { arrivedFrom } from '../visit-campaign.js';
|
|
7
|
+
|
|
6
8
|
export const FORMATS = [
|
|
7
9
|
'popup',
|
|
8
10
|
'sheet',
|
|
@@ -39,6 +41,11 @@ const strings = (v) =>
|
|
|
39
41
|
Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
|
|
40
42
|
const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
|
|
41
43
|
|
|
44
|
+
/** A rule no visit meets: what an `arrived_from` this SDK cannot read becomes, so it fails closed. */
|
|
45
|
+
const NEVER = { field: null, match: null, values: [] };
|
|
46
|
+
const arrivedRules = (raw) =>
|
|
47
|
+
raw == null ? [] : Array.isArray(raw) && raw.every(isObject) ? raw : [NEVER];
|
|
48
|
+
|
|
42
49
|
/** A form from the settings as the runtime uses it, or null when it cannot be used. */
|
|
43
50
|
export function readForm(raw) {
|
|
44
51
|
if (!isObject(raw) || typeof raw.slot !== 'string' || raw.slot === '')
|
|
@@ -55,6 +62,7 @@ export function readForm(raw) {
|
|
|
55
62
|
devices: strings(raw.devices),
|
|
56
63
|
aliases: strings(raw.aliases),
|
|
57
64
|
audiences: strings(raw.audiences),
|
|
65
|
+
arrived_from: arrivedRules(raw.arrived_from),
|
|
58
66
|
caps: {
|
|
59
67
|
per_session: count(caps.per_session),
|
|
60
68
|
lifetime: count(caps.lifetime),
|
|
@@ -123,25 +131,32 @@ const at = (iso) => (typeof iso === 'string' ? Date.parse(iso) : Number.NaN);
|
|
|
123
131
|
/**
|
|
124
132
|
* A form the site opens itself (`manual`: its own sign-up button) is the shopper asking for it, so
|
|
125
133
|
* how often it has shown does not hold it back: no caps, gap or page view allowance, as Klaviyo's
|
|
126
|
-
* custom-triggered forms ignore their frequency settings. Its pages, devices, dates and
|
|
127
|
-
* still do.
|
|
134
|
+
* custom-triggered forms ignore their frequency settings. Its pages, devices, campaign, dates and
|
|
135
|
+
* audiences still do.
|
|
128
136
|
*/
|
|
129
137
|
export const ASKED = (form) => form.trigger.type === 'manual';
|
|
130
138
|
|
|
131
139
|
/**
|
|
132
140
|
* Every reason `form` may not show now, empty when it may. `env` is the page:
|
|
133
|
-
* `{ path, device, now, seen, subscribed, personalisation, preview, audiences }`, where
|
|
134
|
-
* the slot's counters and `audiences` the
|
|
141
|
+
* `{ path, device, campaign, now, seen, subscribed, personalisation, preview, audiences }`, where
|
|
142
|
+
* `campaign` is the visit's (`visitCampaign`), `seen` the slot's counters and `audiences` the
|
|
143
|
+
* session audiences the host gave, or null. The campaign is context about this visit, as the page
|
|
144
|
+
* is, so it needs no personalisation consent. A form limited to audiences needs personalisation
|
|
145
|
+
* consent; whether the visitor is in one is the server's to decide (`POST /v1/decisions`), unless
|
|
146
|
+
* the host gave its own list.
|
|
135
147
|
*/
|
|
136
148
|
export function blockers(form, env) {
|
|
137
149
|
const reasons = [];
|
|
138
150
|
if (!pageAllowed(form.pages, env.path)) reasons.push('page not included');
|
|
139
151
|
if (form.devices.length && !form.devices.includes(env.device))
|
|
140
152
|
reasons.push(`not on ${env.device}`);
|
|
153
|
+
if (!arrivedFrom(form.arrived_from, env.campaign))
|
|
154
|
+
reasons.push('not from its campaign');
|
|
141
155
|
const starts = at(form.window.starts_at);
|
|
142
156
|
const ends = at(form.window.ends_at);
|
|
143
157
|
if (env.now < starts || env.now >= ends) reasons.push('outside its dates');
|
|
144
|
-
|
|
158
|
+
// A trader previewing a limited form sees it, as the server answers a preview.
|
|
159
|
+
if (form.audiences.length && !env.preview) {
|
|
145
160
|
if (!env.personalisation)
|
|
146
161
|
reasons.push('audiences need personalisation consent');
|
|
147
162
|
else if (
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session audiences whose rules hold in this visit, for a form limited to audiences (ADR 0064):
|
|
3
|
+
* the server decides membership, but the live tier counts only product views and cart adds, so the
|
|
4
|
+
* browser evaluates the datafile's session rules and sends the slugs as `visit_audiences`. Rules
|
|
5
|
+
* only: `requires`, MIXED and targetability are the server's to apply. A chunk of its own
|
|
6
|
+
* (./audiences-loader.js), loaded with the datafile as the runtime starts, only when a live form is
|
|
7
|
+
* limited and the visitor allowed personalisation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { catalogueBrowse, sessionRulesHold } from '../session/rules.js';
|
|
11
|
+
|
|
12
|
+
/** What the decide request accepts: more, or a longer slug, and it ignores the lot. */
|
|
13
|
+
const MAX_AUDIENCES = 100;
|
|
14
|
+
/** Past any slug the console makes: a name of at most 100 characters, and a `-2` to tell twins apart. */
|
|
15
|
+
const MAX_SLUG = 128;
|
|
16
|
+
|
|
17
|
+
const isSlug = (slug) =>
|
|
18
|
+
typeof slug === 'string' && slug !== '' && slug.length <= MAX_SLUG;
|
|
19
|
+
|
|
20
|
+
/** The datafile for each onsite runtime's api: a 200 kept for the page, anything else asked again. */
|
|
21
|
+
const datafiles = new WeakMap();
|
|
22
|
+
|
|
23
|
+
/** Start fetching the datafile, so a form that fires later does not wait for it. */
|
|
24
|
+
export function prefetch(api) {
|
|
25
|
+
if (!datafiles.has(api)) {
|
|
26
|
+
const fetching = api
|
|
27
|
+
.datafile()
|
|
28
|
+
.then((res) => (res.status === 200 ? res.body : null))
|
|
29
|
+
.catch(() => null);
|
|
30
|
+
datafiles.set(api, fetching);
|
|
31
|
+
fetching.then((body) => {
|
|
32
|
+
if (!body && datafiles.get(api) === fetching) datafiles.delete(api);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return datafiles.get(api);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The slugs to send: those whose rules hold in this visit (the datafile fetched once, the visit read
|
|
40
|
+
* each time) and those the page gave, once each, at most MAX_AUDIENCES of them. Past that, the form's
|
|
41
|
+
* own audiences and what they require go first, so the cap never drops the ones it asks about.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ datafile: () => Promise<{ status: number, body: any }> }} api ./api.js
|
|
44
|
+
* @param {() => object | null | undefined} [signals] This visit's session signals, kept only under
|
|
45
|
+
* personalisation consent.
|
|
46
|
+
* @param {string[]} [targets] The audiences the form is limited to.
|
|
47
|
+
* @param {string[]} [given] The session audiences the page gave (`startOnsite`'s `audiences()`).
|
|
48
|
+
* @returns {Promise<string[]>}
|
|
49
|
+
*/
|
|
50
|
+
export async function visitAudiences(api, signals, targets = [], given = []) {
|
|
51
|
+
const datafile = await prefetch(api);
|
|
52
|
+
const visit = signals?.();
|
|
53
|
+
const session = Array.isArray(datafile?.session_audiences)
|
|
54
|
+
? datafile.session_audiences
|
|
55
|
+
: [];
|
|
56
|
+
const held = datafile && visit ? holdingAudiences(datafile, visit) : [];
|
|
57
|
+
return firstWanted([...held, ...given], targets, session);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `slugs`, once each, `targets` and their transitive `requires` first, capped. */
|
|
61
|
+
export function firstWanted(slugs, targets, session) {
|
|
62
|
+
const requires = new Map(
|
|
63
|
+
session.map((a) => [a?.slug, Array.isArray(a?.requires) ? a.requires : []]),
|
|
64
|
+
);
|
|
65
|
+
const wanted = new Set();
|
|
66
|
+
const visit = (slug) => {
|
|
67
|
+
if (wanted.has(slug)) return;
|
|
68
|
+
wanted.add(slug);
|
|
69
|
+
for (const required of requires.get(slug) ?? []) visit(required);
|
|
70
|
+
};
|
|
71
|
+
for (const slug of targets) visit(slug);
|
|
72
|
+
const unique = [...new Set(slugs.filter(isSlug))];
|
|
73
|
+
return [
|
|
74
|
+
...unique.filter((slug) => wanted.has(slug)),
|
|
75
|
+
...unique.filter((slug) => !wanted.has(slug)),
|
|
76
|
+
].slice(0, MAX_AUDIENCES);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{ session_audiences?: unknown, browse_categories?: unknown }} datafile `GET /v1/audiences/datafile`.
|
|
81
|
+
* @param {object} signals This visit's session signals.
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
export function holdingAudiences(datafile, signals) {
|
|
85
|
+
const audiences = Array.isArray(datafile?.session_audiences)
|
|
86
|
+
? datafile.session_audiences
|
|
87
|
+
: [];
|
|
88
|
+
const visit = catalogueBrowse(signals, datafile.browse_categories ?? null);
|
|
89
|
+
return audiences
|
|
90
|
+
.filter(
|
|
91
|
+
(audience) => isSlug(audience?.slug) && sessionRulesHold(audience, visit),
|
|
92
|
+
)
|
|
93
|
+
.map((audience) => audience.slug);
|
|
94
|
+
}
|
package/src/session/record.js
CHANGED
|
@@ -58,11 +58,16 @@ function countListView(s, category) {
|
|
|
58
58
|
* Fold one CDP event into the session signals.
|
|
59
59
|
*
|
|
60
60
|
* Driven by the events the SDK already sends, so a session rule is only ever about something the
|
|
61
|
-
* warehouse will also see.
|
|
61
|
+
* warehouse will also see. `campaign`, when given, is the visit's campaign (`readVisitCampaign`),
|
|
62
|
+
* mirrored as it stands so the two never disagree; left out, the kept one stays.
|
|
62
63
|
*/
|
|
63
|
-
export function recordSessionEvent(eventName, properties) {
|
|
64
|
+
export function recordSessionEvent(eventName, properties, campaign) {
|
|
64
65
|
const s = readSignals();
|
|
65
66
|
if (!s.startedAt) s.startedAt = Date.now();
|
|
67
|
+
const arrived =
|
|
68
|
+
campaign !== undefined &&
|
|
69
|
+
JSON.stringify(campaign ?? null) !== JSON.stringify(s.campaign ?? null);
|
|
70
|
+
if (arrived) s.campaign = campaign ?? null;
|
|
66
71
|
|
|
67
72
|
const products = Array.isArray(properties?.products)
|
|
68
73
|
? properties.products
|
|
@@ -178,6 +183,7 @@ export function recordSessionEvent(eventName, properties) {
|
|
|
178
183
|
return s;
|
|
179
184
|
}
|
|
180
185
|
default:
|
|
186
|
+
if (arrived) writeSignals(s);
|
|
181
187
|
return s;
|
|
182
188
|
}
|
|
183
189
|
|
package/src/session/rules.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CAMPAIGN_FIELDS, tagMatches } from '../visit-campaign.js';
|
|
1
2
|
import { categoryKey } from './category.js';
|
|
2
3
|
import { readSignals } from './signals.js';
|
|
3
4
|
|
|
@@ -17,12 +18,28 @@ const OPERATORS = {
|
|
|
17
18
|
'=': (a, b) => a === b,
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Operators on a text field (the visit's campaign tags): `tagMatches`, the matcher a form's
|
|
23
|
+
* "arrived from" rule uses, so the two agree. A value of the wrong shape matches nothing.
|
|
24
|
+
*/
|
|
25
|
+
const text = (v) => typeof v === 'string';
|
|
26
|
+
const is = (a, b) => text(b) && tagMatches(a, 'is', b);
|
|
27
|
+
const STRING_OPERATORS = {
|
|
28
|
+
eq: is,
|
|
29
|
+
equals: is,
|
|
30
|
+
'=': is,
|
|
31
|
+
neq: (a, b) => text(b) && !is(a, b),
|
|
32
|
+
in: (a, b) => Array.isArray(b) && b.some((v) => is(a, v)),
|
|
33
|
+
contains: (a, b) => text(b) && tagMatches(a, 'contains', b),
|
|
34
|
+
};
|
|
35
|
+
|
|
20
36
|
/**
|
|
21
37
|
* Every operator this evaluator can apply, including `between`. A contract test keeps the session
|
|
22
38
|
* trait catalogue inside this set, since an unsupported operator fails closed silently.
|
|
23
39
|
*/
|
|
24
40
|
export const SESSION_OPERATORS = new Set([
|
|
25
41
|
...Object.keys(OPERATORS),
|
|
42
|
+
...Object.keys(STRING_OPERATORS),
|
|
26
43
|
'between',
|
|
27
44
|
]);
|
|
28
45
|
|
|
@@ -54,14 +71,23 @@ const MEASURES = {
|
|
|
54
71
|
/**
|
|
55
72
|
* What a rule's field is worth right now, or null if this build does not know the field.
|
|
56
73
|
*
|
|
57
|
-
* A field is a counter name
|
|
58
|
-
*
|
|
74
|
+
* A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`), or one of the
|
|
75
|
+
* visit's campaign tags (`campaign.source`), which is text and null for a visit that arrived
|
|
76
|
+
* untagged. Exported so a storefront can explain a personalisation. Unknown fields fail the rule
|
|
77
|
+
* closed.
|
|
59
78
|
*/
|
|
60
79
|
export function measureField(field, signals) {
|
|
61
80
|
const separator = field.indexOf('.');
|
|
62
81
|
if (separator > 0) {
|
|
63
|
-
const
|
|
82
|
+
const name = field.slice(0, separator);
|
|
64
83
|
const arg = field.slice(separator + 1);
|
|
84
|
+
if (name === 'campaign') {
|
|
85
|
+
const tag = CAMPAIGN_FIELDS.includes(arg)
|
|
86
|
+
? signals.campaign?.[arg]
|
|
87
|
+
: null;
|
|
88
|
+
return typeof tag === 'string' ? tag : null;
|
|
89
|
+
}
|
|
90
|
+
const fn = MEASURES[name];
|
|
65
91
|
return fn && arg ? fn(signals, arg) : null;
|
|
66
92
|
}
|
|
67
93
|
const value = signals[field];
|
|
@@ -71,6 +97,10 @@ export function measureField(field, signals) {
|
|
|
71
97
|
export function evaluateSessionRule(rule, signals) {
|
|
72
98
|
const actual = measureField(rule.field, signals);
|
|
73
99
|
if (actual === null) return false;
|
|
100
|
+
if (typeof actual === 'string') {
|
|
101
|
+
const compare = STRING_OPERATORS[rule.operator];
|
|
102
|
+
return compare ? compare(actual, rule.value) : false;
|
|
103
|
+
}
|
|
74
104
|
if (rule.operator === 'between') {
|
|
75
105
|
// A `between` missing either bound fails closed.
|
|
76
106
|
return (
|