@thg-agentic/hyper-personalisation-web 3.29.0 → 3.31.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 +19 -2
- package/dist/hp.min.js +2 -2
- package/dist/onsite-audiences.min.js +1 -1
- package/dist/onsite-render.min.js +5 -5
- package/dist/onsite.min.js +1 -1
- package/package.json +2 -2
- package/src/answer-memory.js +101 -0
- package/src/index.js +42 -4
- package/src/onsite/api.js +21 -4
- package/src/onsite/index.js +6 -3
- package/src/onsite/render.js +78 -36
- package/src/onsite/visit-audiences.js +17 -7
- package/src/session/rules.js +62 -7
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +22 -0
- package/src/version.js +1 -1
- package/src/visit-campaign.js +2 -8
- package/types/events.d.ts +3 -2
- package/types/index.d.ts +5 -0
package/src/onsite/render.js
CHANGED
|
@@ -422,7 +422,11 @@ export function mountForm(o) {
|
|
|
422
422
|
modal: false,
|
|
423
423
|
mobile: preview?.device === 'mobile',
|
|
424
424
|
answers: {},
|
|
425
|
+
// The step each answer was given on, by question.
|
|
426
|
+
answeredOn: {},
|
|
425
427
|
sent: new Set(),
|
|
428
|
+
// Whether this view's click is counted: a view counts one, whatever was clicked.
|
|
429
|
+
clicked: false,
|
|
426
430
|
// The products blocks' rails reported seen, by id: a step shown again does not report it twice.
|
|
427
431
|
seen: new Set(),
|
|
428
432
|
receipt: null,
|
|
@@ -481,8 +485,17 @@ export function mountForm(o) {
|
|
|
481
485
|
small.addEventListener?.('change', resize);
|
|
482
486
|
}
|
|
483
487
|
|
|
488
|
+
/** A form that collects nothing: a message, products, or a quiz. */
|
|
489
|
+
const asksNothing = !form.channels?.length;
|
|
490
|
+
|
|
484
491
|
const engaged = (engagement, step, item = {}) => {
|
|
485
492
|
if (preview || !decision.decision_id || !access.analytics()) return;
|
|
493
|
+
// A view counts one click; a products card's always says which product was clicked.
|
|
494
|
+
if (engagement === 'click') {
|
|
495
|
+
const counted = state.clicked;
|
|
496
|
+
state.clicked = true;
|
|
497
|
+
if (counted && !item.products) return;
|
|
498
|
+
}
|
|
486
499
|
client.slotEngaged({
|
|
487
500
|
decision_id: decision.decision_id,
|
|
488
501
|
slot: decision.slot,
|
|
@@ -768,8 +781,9 @@ export function mountForm(o) {
|
|
|
768
781
|
node.href = href;
|
|
769
782
|
node.rel = 'noopener';
|
|
770
783
|
node.addEventListener('click', (e) => {
|
|
771
|
-
if (preview) e.preventDefault();
|
|
772
|
-
|
|
784
|
+
if (preview) return e.preventDefault();
|
|
785
|
+
engaged('click');
|
|
786
|
+
leaving();
|
|
773
787
|
});
|
|
774
788
|
return node;
|
|
775
789
|
}
|
|
@@ -1184,54 +1198,76 @@ export function mountForm(o) {
|
|
|
1184
1198
|
(i) => i.value,
|
|
1185
1199
|
),
|
|
1186
1200
|
].filter(Boolean);
|
|
1187
|
-
if (values.length)
|
|
1188
|
-
|
|
1201
|
+
if (values.length) {
|
|
1202
|
+
state.answers[key] = values;
|
|
1203
|
+
state.answeredOn[key] = state.step.id;
|
|
1204
|
+
} else delete state.answers[key];
|
|
1189
1205
|
}
|
|
1190
1206
|
}
|
|
1191
1207
|
|
|
1192
|
-
/**
|
|
1208
|
+
/**
|
|
1209
|
+
* An answer the gateway did not carry is the page's to report: after the sign-up, or, on a form
|
|
1210
|
+
* that collects nothing (a quiz), as each step's answers are given.
|
|
1211
|
+
*/
|
|
1193
1212
|
function stated() {
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
answers.push({ question, values });
|
|
1200
|
-
}
|
|
1201
|
-
const step = state.step.id;
|
|
1213
|
+
const fresh = Object.entries(state.answers)
|
|
1214
|
+
.map(([question, values]) => ({ question, values }))
|
|
1215
|
+
.filter((a) => !state.sent.has(marker(a)));
|
|
1216
|
+
// Each answer goes with the step it was given on (collect records it), maybe an earlier one.
|
|
1217
|
+
const stepOf = ({ question }) => state.answeredOn[question];
|
|
1202
1218
|
// After the email step, the gateway relays them to that address's profile and records them.
|
|
1203
|
-
if (state.signedUp
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
step,
|
|
1211
|
-
...state.signedUp,
|
|
1212
|
-
answers,
|
|
1213
|
-
context: pageContext(),
|
|
1214
|
-
});
|
|
1215
|
-
if (access.analytics())
|
|
1216
|
-
for (const answer of answers)
|
|
1217
|
-
client.preferencesStated({
|
|
1218
|
-
...answer,
|
|
1219
|
-
source: 'onsite',
|
|
1219
|
+
if (state.signedUp) {
|
|
1220
|
+
for (const step of new Set(fresh.map(stepOf))) {
|
|
1221
|
+
const answers = fresh.filter((a) => stepOf(a) === step);
|
|
1222
|
+
for (const answer of answers) state.sent.add(marker(answer));
|
|
1223
|
+
api.answers({
|
|
1224
|
+
anonymousId: access.anonymousId(),
|
|
1225
|
+
sessionId: access.sessionId() ?? undefined,
|
|
1220
1226
|
slot: decision.slot,
|
|
1221
|
-
arm: decision.arm ??
|
|
1222
|
-
decision_id: decision.decision_id ??
|
|
1227
|
+
arm: decision.arm ?? undefined,
|
|
1228
|
+
decision_id: decision.decision_id ?? undefined,
|
|
1223
1229
|
step,
|
|
1230
|
+
...state.signedUp,
|
|
1231
|
+
answers,
|
|
1232
|
+
context: pageContext(),
|
|
1224
1233
|
});
|
|
1234
|
+
}
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
// Kept back without analytics consent, so a later grant still reports it.
|
|
1238
|
+
if (!access.analytics()) return;
|
|
1239
|
+
for (const answer of fresh) {
|
|
1240
|
+
state.sent.add(marker(answer));
|
|
1241
|
+
client.preferencesStated({
|
|
1242
|
+
...answer,
|
|
1243
|
+
source: 'onsite',
|
|
1244
|
+
slot: decision.slot,
|
|
1245
|
+
arm: decision.arm ?? null,
|
|
1246
|
+
decision_id: decision.decision_id ?? null,
|
|
1247
|
+
step: stepOf(answer),
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1225
1250
|
}
|
|
1226
1251
|
|
|
1252
|
+
const marker = ({ question, values }) => `${question}=${values.join(',')}`;
|
|
1253
|
+
|
|
1254
|
+
/** A quiz left by a link or closed keeps the answers given so far, as on moving on. */
|
|
1255
|
+
const leaving = () => {
|
|
1256
|
+
if (!asksNothing) return;
|
|
1257
|
+
collect();
|
|
1258
|
+
stated();
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1227
1261
|
const pageContext = () => ({
|
|
1228
1262
|
page: { url: access.pageUrl() },
|
|
1229
1263
|
locale: access.locale() ?? undefined,
|
|
1230
1264
|
consent: access.consent(),
|
|
1231
1265
|
});
|
|
1232
1266
|
|
|
1267
|
+
// Answers are reported as the form moves on: after a receipt, or on a form that collects nothing.
|
|
1268
|
+
// A sign-up form's answers before the sign-up travel with it.
|
|
1233
1269
|
const advance = (explicit) => {
|
|
1234
|
-
if (state.receipt
|
|
1270
|
+
if (!preview && (state.receipt || asksNothing)) stated();
|
|
1235
1271
|
show(nextStep(state.step, state.answers, explicit));
|
|
1236
1272
|
};
|
|
1237
1273
|
|
|
@@ -1475,14 +1511,19 @@ export function mountForm(o) {
|
|
|
1475
1511
|
// A preview only moves between steps: nothing is checked, sent or claimed.
|
|
1476
1512
|
if (preview) return advance(block.next);
|
|
1477
1513
|
if (wrong) return say(wrong.error, wrong.input);
|
|
1514
|
+
// A quiz learns from clicks: moving on with an answer is a click.
|
|
1515
|
+
if (
|
|
1516
|
+
asksNothing &&
|
|
1517
|
+
state.step.blocks.some((b) => state.answers[b?.question])
|
|
1518
|
+
)
|
|
1519
|
+
engaged('click');
|
|
1478
1520
|
if (block.action === 'apply') {
|
|
1479
1521
|
o.emit?.('apply', {
|
|
1480
1522
|
coupon: state.claim?.code ? { code: state.claim.code } : null,
|
|
1481
1523
|
});
|
|
1482
1524
|
engaged('click');
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
: close('done');
|
|
1525
|
+
// With no step after it, the form closes, done.
|
|
1526
|
+
return advance(block.next);
|
|
1486
1527
|
}
|
|
1487
1528
|
if (block.action === 'submit') return submit(block);
|
|
1488
1529
|
if (block.action === 'spin') return spin(block);
|
|
@@ -1520,6 +1561,7 @@ export function mountForm(o) {
|
|
|
1520
1561
|
function close(reason) {
|
|
1521
1562
|
// A preview stays on screen: closing is the shopper's, not the trader's.
|
|
1522
1563
|
if (state.closed || preview) return;
|
|
1564
|
+
leaving();
|
|
1523
1565
|
if (!state.submitted && reason !== 'done') engaged('dismiss');
|
|
1524
1566
|
counters.closed(form.slot);
|
|
1525
1567
|
counters.tab?.(form.slot, false);
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* limited and the visitor allowed personalisation.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
answeredRulesHold,
|
|
12
|
+
catalogueBrowse,
|
|
13
|
+
sessionRulesHold,
|
|
14
|
+
} from '../session/rules.js';
|
|
11
15
|
|
|
12
16
|
/** What the decide request accepts: more, or a longer slug, and it ignores the lot. */
|
|
13
17
|
const MAX_AUDIENCES = 100;
|
|
@@ -45,7 +49,9 @@ export function prefetch(api) {
|
|
|
45
49
|
* personalisation consent.
|
|
46
50
|
* @param {string[]} [targets] The audiences the form is limited to.
|
|
47
51
|
* @param {string[]} [given] The session audiences the page gave (`startOnsite`'s `audiences()`).
|
|
48
|
-
* @returns {Promise<string[]>}
|
|
52
|
+
* @returns {Promise<{ claims: string[], answered: string[] }>} `claims`, the slugs to send as
|
|
53
|
+
* `visit_audiences`; `answered`, the early audiences that hold on the answers this device keeps,
|
|
54
|
+
* as `answered_audiences`.
|
|
49
55
|
*/
|
|
50
56
|
export async function visitAudiences(api, signals, targets = [], given = []) {
|
|
51
57
|
const datafile = await prefetch(api);
|
|
@@ -54,7 +60,12 @@ export async function visitAudiences(api, signals, targets = [], given = []) {
|
|
|
54
60
|
? datafile.session_audiences
|
|
55
61
|
: [];
|
|
56
62
|
const held = datafile && visit ? holdingAudiences(datafile, visit) : [];
|
|
57
|
-
|
|
63
|
+
const claims = firstWanted([...held, ...given], targets, session);
|
|
64
|
+
const answered =
|
|
65
|
+
datafile && visit
|
|
66
|
+
? holdingAudiences(datafile, visit, answeredRulesHold)
|
|
67
|
+
: [];
|
|
68
|
+
return { claims, answered: firstWanted(answered, targets, session) };
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
/** `slugs`, once each, `targets` and their transitive `requires` first, capped. */
|
|
@@ -79,16 +90,15 @@ export function firstWanted(slugs, targets, session) {
|
|
|
79
90
|
/**
|
|
80
91
|
* @param {{ session_audiences?: unknown, browse_categories?: unknown }} datafile `GET /v1/audiences/datafile`.
|
|
81
92
|
* @param {object} signals This visit's session signals.
|
|
93
|
+
* @param {(audience: object, signals: object) => boolean} [holds]
|
|
82
94
|
* @returns {string[]}
|
|
83
95
|
*/
|
|
84
|
-
export function holdingAudiences(datafile, signals) {
|
|
96
|
+
export function holdingAudiences(datafile, signals, holds = sessionRulesHold) {
|
|
85
97
|
const audiences = Array.isArray(datafile?.session_audiences)
|
|
86
98
|
? datafile.session_audiences
|
|
87
99
|
: [];
|
|
88
100
|
const visit = catalogueBrowse(signals, datafile.browse_categories ?? null);
|
|
89
101
|
return audiences
|
|
90
|
-
.filter(
|
|
91
|
-
(audience) => isSlug(audience?.slug) && sessionRulesHold(audience, visit),
|
|
92
|
-
)
|
|
102
|
+
.filter((audience) => isSlug(audience?.slug) && holds(audience, visit))
|
|
93
103
|
.map((audience) => audience.slug);
|
|
94
104
|
}
|
package/src/session/rules.js
CHANGED
|
@@ -33,6 +33,16 @@ const STRING_OPERATORS = {
|
|
|
33
33
|
contains: (a, b) => text(b) && tagMatches(a, 'contains', b),
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Operators on an answer (`answer.<question>`): the values the shopper chose against the rule's
|
|
38
|
+
* list, as the SQL compiler reads them. With no answer kept, "is none of" holds, as `not exists`.
|
|
39
|
+
*/
|
|
40
|
+
const LIST_OPERATORS = {
|
|
41
|
+
in: (chosen, list) => list.some((v) => chosen.includes(v)),
|
|
42
|
+
not_in: (chosen, list) => !list.some((v) => chosen.includes(v)),
|
|
43
|
+
all: (chosen, list) => list.every((v) => chosen.includes(v)),
|
|
44
|
+
};
|
|
45
|
+
|
|
36
46
|
/**
|
|
37
47
|
* Every operator this evaluator can apply, including `between`. A contract test keeps the session
|
|
38
48
|
* trait catalogue inside this set, since an unsupported operator fails closed silently.
|
|
@@ -40,6 +50,7 @@ const STRING_OPERATORS = {
|
|
|
40
50
|
export const SESSION_OPERATORS = new Set([
|
|
41
51
|
...Object.keys(OPERATORS),
|
|
42
52
|
...Object.keys(STRING_OPERATORS),
|
|
53
|
+
...Object.keys(LIST_OPERATORS),
|
|
43
54
|
'between',
|
|
44
55
|
]);
|
|
45
56
|
|
|
@@ -71,10 +82,11 @@ const MEASURES = {
|
|
|
71
82
|
/**
|
|
72
83
|
* What a rule's field is worth right now, or null if this build does not know the field.
|
|
73
84
|
*
|
|
74
|
-
* A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`),
|
|
85
|
+
* A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`), one of the
|
|
75
86
|
* visit's campaign tags (`campaign.source`), which is text and null for a visit that arrived
|
|
76
|
-
* untagged
|
|
77
|
-
*
|
|
87
|
+
* untagged, or an answer (`answer.shopping_for`), the values chosen and empty for none, but null
|
|
88
|
+
* where the signals carry no answers (the onsite runtime's do). Exported so a storefront can
|
|
89
|
+
* explain a personalisation. Unknown fields fail the rule closed.
|
|
78
90
|
*/
|
|
79
91
|
export function measureField(field, signals) {
|
|
80
92
|
const separator = field.indexOf('.');
|
|
@@ -87,6 +99,13 @@ export function measureField(field, signals) {
|
|
|
87
99
|
: null;
|
|
88
100
|
return typeof tag === 'string' ? tag : null;
|
|
89
101
|
}
|
|
102
|
+
if (name === 'answer') {
|
|
103
|
+
const { answers } = signals;
|
|
104
|
+
if (!answers || typeof answers !== 'object') return null;
|
|
105
|
+
return Object.hasOwn(answers, arg) && Array.isArray(answers[arg])
|
|
106
|
+
? answers[arg]
|
|
107
|
+
: [];
|
|
108
|
+
}
|
|
90
109
|
const fn = MEASURES[name];
|
|
91
110
|
return fn && arg ? fn(signals, arg) : null;
|
|
92
111
|
}
|
|
@@ -97,6 +116,14 @@ export function measureField(field, signals) {
|
|
|
97
116
|
export function evaluateSessionRule(rule, signals) {
|
|
98
117
|
const actual = measureField(rule.field, signals);
|
|
99
118
|
if (actual === null) return false;
|
|
119
|
+
if (Array.isArray(actual)) {
|
|
120
|
+
const list = Array.isArray(rule.value) && rule.value.length > 0;
|
|
121
|
+
return (
|
|
122
|
+
list &&
|
|
123
|
+
Object.hasOwn(LIST_OPERATORS, rule.operator) &&
|
|
124
|
+
LIST_OPERATORS[rule.operator](actual, rule.value)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
100
127
|
if (typeof actual === 'string') {
|
|
101
128
|
const compare = STRING_OPERATORS[rule.operator];
|
|
102
129
|
return compare ? compare(actual, rule.value) : false;
|
|
@@ -146,6 +173,25 @@ export function sessionRulesHold(audience, signals) {
|
|
|
146
173
|
: groupResults.every(Boolean);
|
|
147
174
|
}
|
|
148
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Whether an early audience (ADR 0064) holds on the answers this device keeps: its visit rules and
|
|
178
|
+
* its `answer_rules` together, grouped as authored. Never when the signals carry no answers.
|
|
179
|
+
*/
|
|
180
|
+
export function answeredRulesHold(audience, signals) {
|
|
181
|
+
return (
|
|
182
|
+
audience.early === true &&
|
|
183
|
+
Array.isArray(audience.answer_rules) &&
|
|
184
|
+
typeof signals?.answers === 'object' &&
|
|
185
|
+
sessionRulesHold(
|
|
186
|
+
{
|
|
187
|
+
...audience,
|
|
188
|
+
rules: [...(audience.rules ?? []), ...audience.answer_rules],
|
|
189
|
+
},
|
|
190
|
+
signals,
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
149
195
|
/**
|
|
150
196
|
* The audiences this visitor is in right now: precomputed membership from the edge, plus any
|
|
151
197
|
* session audience whose rules hold.
|
|
@@ -229,12 +275,21 @@ export function resolveAudienceMemberships(
|
|
|
229
275
|
if (active.has(audience.slug)) continue;
|
|
230
276
|
const required = audience.requires ?? [];
|
|
231
277
|
if (!required.every(met)) continue;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
278
|
+
// An early audience is in from the answers this device keeps, without the warehouse: they are
|
|
279
|
+
// the shopper's own. Otherwise it is the MIXED audience it ships as.
|
|
280
|
+
const source = answeredRulesHold(audience, visit)
|
|
281
|
+
? 'session'
|
|
282
|
+
: (!audience.mixed || qualified.has(audience.slug)) &&
|
|
283
|
+
audience.rules?.length > 0 &&
|
|
284
|
+
sessionRulesHold(audience, visit)
|
|
285
|
+
? audience.mixed
|
|
286
|
+
? 'mixed'
|
|
287
|
+
: 'session'
|
|
288
|
+
: null;
|
|
289
|
+
if (source) {
|
|
235
290
|
active.add(audience.slug);
|
|
236
291
|
// Mixed is its own source: half of it was decided by a batch run, not by this visit.
|
|
237
|
-
bySlug.set(audience.slug,
|
|
292
|
+
bySlug.set(audience.slug, source);
|
|
238
293
|
admitted = true;
|
|
239
294
|
}
|
|
240
295
|
}
|
package/src/spec-version.js
CHANGED
package/src/storage-scope.js
CHANGED
|
@@ -70,6 +70,12 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
|
|
|
70
70
|
* person id (3.26).
|
|
71
71
|
*/
|
|
72
72
|
visitCampaign: 'thg_hp_visit_campaign',
|
|
73
|
+
/**
|
|
74
|
+
* localStorage, per property: the answers given to its onsite forms, each kept 90 days, so an
|
|
75
|
+
* audience on answers is decided as the shopper answers. Only under personalisation consent;
|
|
76
|
+
* question keys and option values, never contact data (3.31).
|
|
77
|
+
*/
|
|
78
|
+
answers: 'thg_hp_answers',
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
/**
|
|
@@ -138,6 +144,22 @@ export function storagePrefix(name, tenant) {
|
|
|
138
144
|
return `${name}:${segment(tenant)}:`;
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Remove every tenant's localStorage keys of one kind on this origin: consent is withdrawn on the
|
|
149
|
+
* device, and a sign-out ends what the device kept for everyone.
|
|
150
|
+
*
|
|
151
|
+
* @param {keyof typeof DEFAULT_STORAGE_NAMES} kind
|
|
152
|
+
*/
|
|
153
|
+
export function forgetEveryScope(kind) {
|
|
154
|
+
const prefix = `${names[kind]}:`;
|
|
155
|
+
try {
|
|
156
|
+
for (const key of Object.keys(localStorage))
|
|
157
|
+
if (key.startsWith(prefix)) localStorage.removeItem(key);
|
|
158
|
+
} catch {
|
|
159
|
+
// Nothing was kept.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
/**
|
|
142
164
|
* Move every legacy `cdp*` key to its current name, once per page, in both stores.
|
|
143
165
|
*
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.31.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/src/visit-campaign.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { SESSION_TIMEOUT_MS } from './session-id.js';
|
|
16
|
-
import { storageKey, storageName } from './storage-scope.js';
|
|
16
|
+
import { forgetEveryScope, storageKey, storageName } from './storage-scope.js';
|
|
17
17
|
|
|
18
18
|
/** The longest a tag is read: the control plane's CAMPAIGN_VALUE_MAX, which the server cuts to. */
|
|
19
19
|
const TAG_MAX = 100;
|
|
@@ -167,11 +167,5 @@ export function keepVisitCampaign(
|
|
|
167
167
|
/** Forget every property's visit campaign on this origin: a sign-out, or consent withdrawn. */
|
|
168
168
|
export function clearVisitCampaign() {
|
|
169
169
|
memory.clear();
|
|
170
|
-
|
|
171
|
-
const prefix = `${storageName('visitCampaign')}:`;
|
|
172
|
-
for (const key of Object.keys(localStorage))
|
|
173
|
-
if (key.startsWith(prefix)) localStorage.removeItem(key);
|
|
174
|
-
} catch {
|
|
175
|
-
/* nothing was kept */
|
|
176
|
-
}
|
|
170
|
+
forgetEveryScope('visitCampaign');
|
|
177
171
|
}
|
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.
|
|
4
|
+
// Spec version: 1.22
|
|
5
5
|
|
|
6
6
|
/** context.source.environment. Environment the producer runs in. */
|
|
7
7
|
export type CdpSourceEnvironment = 'production' | 'staging' | 'development';
|
|
@@ -104,6 +104,7 @@ export interface RecommendationServed {
|
|
|
104
104
|
strategy?: string | null;
|
|
105
105
|
algorithm_version?: string | null;
|
|
106
106
|
candidate_algorithm_version?: string | null;
|
|
107
|
+
shadow_algorithm_version?: string | null;
|
|
107
108
|
ranked_by?: 'ranker' | 'blend' | 'session' | 'manual' | 'other' | null;
|
|
108
109
|
fusion?: string | null;
|
|
109
110
|
fast_weight?: number | null;
|
|
@@ -713,7 +714,7 @@ export interface SignUpRelayed {
|
|
|
713
714
|
}
|
|
714
715
|
|
|
715
716
|
/** The spec version these models implement. SDKs stamp it on context.spec.version. */
|
|
716
|
-
export const SPEC_VERSION = '1.
|
|
717
|
+
export const SPEC_VERSION = '1.22';
|
|
717
718
|
|
|
718
719
|
/** Every event name the registry accepts. Anything else is rejected at the ingest. */
|
|
719
720
|
export type CdpEventName =
|
package/types/index.d.ts
CHANGED
|
@@ -496,6 +496,11 @@ export interface StorageNames {
|
|
|
496
496
|
preview?: string;
|
|
497
497
|
/** sessionStorage: the debug token from `?hp_debug=` (3.18). Default 'thg_hp_debug'. */
|
|
498
498
|
debug?: string;
|
|
499
|
+
/**
|
|
500
|
+
* localStorage, per property, under personalisation consent: the answers given to its onsite
|
|
501
|
+
* forms, each kept 90 days (3.31). Default 'thg_hp_answers'.
|
|
502
|
+
*/
|
|
503
|
+
answers?: string;
|
|
499
504
|
}
|
|
500
505
|
|
|
501
506
|
/** Where a source's device ids come from: the settings' `hp.anonymousId` (since 3.6). */
|