@cxpinsight/survey-spec 0.3.0 → 0.5.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/dist/evaluateTree.d.ts +50 -0
- package/dist/evaluateTree.d.ts.map +1 -0
- package/dist/evaluateTree.js +166 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/messages.d.ts +38 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +68 -0
- package/dist/resolveVariable.d.ts.map +1 -1
- package/dist/resolveVariable.js +13 -0
- package/dist/validate.d.ts +314 -39
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +577 -24
- package/dist/voice.d.ts.map +1 -1
- package/dist/voice.js +3 -0
- package/package.json +1 -1
package/dist/validate.js
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateAnswer = validateAnswer;
|
|
4
|
+
exports.validateAnswerCode = validateAnswerCode;
|
|
5
|
+
exports.impliedChoices = impliedChoices;
|
|
6
|
+
exports.impliedActions = impliedActions;
|
|
7
|
+
exports.isDeferChoice = isDeferChoice;
|
|
8
|
+
exports.safeActionHref = safeActionHref;
|
|
9
|
+
exports.safeImageSrc = safeImageSrc;
|
|
10
|
+
exports.impliedStepActions = impliedStepActions;
|
|
11
|
+
exports.impliedEqualProminence = impliedEqualProminence;
|
|
12
|
+
exports.canSwipeToDismiss = canSwipeToDismiss;
|
|
13
|
+
exports.autoDismissMs = autoDismissMs;
|
|
14
|
+
exports.deadlineCountdown = deadlineCountdown;
|
|
15
|
+
exports.formatCountdown = formatCountdown;
|
|
16
|
+
exports.outcomeFor = outcomeFor;
|
|
17
|
+
exports.voiceUsable = voiceUsable;
|
|
18
|
+
exports.rewardToShow = rewardToShow;
|
|
19
|
+
exports.cardClickTarget = cardClickTarget;
|
|
20
|
+
exports.cardHoverEffect = cardHoverEffect;
|
|
21
|
+
exports.resolveAction = resolveAction;
|
|
22
|
+
const messages_1 = require("./messages");
|
|
2
23
|
/**
|
|
3
24
|
* Answer validation — whether a respondent may submit.
|
|
4
25
|
*
|
|
@@ -37,8 +58,6 @@
|
|
|
37
58
|
* "that is not a valid email" — which sounds like the shape is wrong rather
|
|
38
59
|
* than the field being blank.
|
|
39
60
|
*/
|
|
40
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
-
exports.validateAnswer = validateAnswer;
|
|
42
61
|
/**
|
|
43
62
|
* Practical email format — matches what the HTML5 email validator accepts.
|
|
44
63
|
* Not RFC 5322-strict: that regex is a page long and rejects addresses real
|
|
@@ -47,14 +66,25 @@ exports.validateAnswer = validateAnswer;
|
|
|
47
66
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
48
67
|
/** URL with a scheme. No guessing at whether `http://` was meant. */
|
|
49
68
|
const URL_RE = /^https?:\/\/[\w.\-]+(?:\.[\w.\-]+)+(?:[/?#][^\s]*)?$/i;
|
|
50
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Null when the answer is valid; otherwise the ENGLISH message to show.
|
|
71
|
+
*
|
|
72
|
+
* The signature is unchanged on purpose — three renderers call this and expect
|
|
73
|
+
* a string. The wording now comes from one table (messages.ts) rather than
|
|
74
|
+
* being built inside the rules, so a renderer that wants another language calls
|
|
75
|
+
* `validateAnswerCode` and words the code itself.
|
|
76
|
+
*/
|
|
51
77
|
function validateAnswer(question, value) {
|
|
78
|
+
return (0, messages_1.wordMessage)(validateAnswerCode(question, value));
|
|
79
|
+
}
|
|
80
|
+
/** The same rules, as a code and its parameters. This is what a port implements. */
|
|
81
|
+
function validateAnswerCode(question, value) {
|
|
52
82
|
// BOTH spellings. The v1 vocabulary and the builder both emit `isRequired`,
|
|
53
83
|
// and a renderer reading only `required` let a required question be submitted
|
|
54
84
|
// blank while the other channels rejected it.
|
|
55
85
|
const empty = isEmpty(question, value);
|
|
56
86
|
if ((question.required || question.isRequired) && empty) {
|
|
57
|
-
return '
|
|
87
|
+
return { code: 'required', params: {} };
|
|
58
88
|
}
|
|
59
89
|
// An optional field left blank is valid. Complaining about the shape of an
|
|
60
90
|
// answer nobody gave is noise.
|
|
@@ -120,6 +150,8 @@ function validateByType(question, value) {
|
|
|
120
150
|
case 'ces':
|
|
121
151
|
case 'nps':
|
|
122
152
|
return validateNumericRange(question, value);
|
|
153
|
+
case 'implied':
|
|
154
|
+
return validateImplied(question, value);
|
|
123
155
|
default:
|
|
124
156
|
return null;
|
|
125
157
|
}
|
|
@@ -143,24 +175,24 @@ function validateTextish(question, value) {
|
|
|
143
175
|
const inputType = question.inputType
|
|
144
176
|
|| (question.type === 'email' ? 'email' : question.type === 'number' ? 'number' : undefined);
|
|
145
177
|
if (inputType === 'email' && !EMAIL_RE.test(trimmed)) {
|
|
146
|
-
return
|
|
178
|
+
return { code: 'format_email', params: {} };
|
|
147
179
|
}
|
|
148
180
|
if (inputType === 'url' && !URL_RE.test(trimmed)) {
|
|
149
|
-
return
|
|
181
|
+
return { code: 'format_url', params: {} };
|
|
150
182
|
}
|
|
151
183
|
if (inputType === 'number' && Number.isNaN(Number(trimmed))) {
|
|
152
|
-
return '
|
|
184
|
+
return { code: 'format_number', params: {} };
|
|
153
185
|
}
|
|
154
186
|
if (inputType === 'tel' && !/^[\d\s+\-()]{3,}$/.test(trimmed)) {
|
|
155
187
|
// Deliberately permissive. Country-specific phone validation inline is a
|
|
156
188
|
// losing game that mostly rejects real numbers.
|
|
157
|
-
return
|
|
189
|
+
return { code: 'format_tel', params: {} };
|
|
158
190
|
}
|
|
159
191
|
if (typeof question.maxLength === 'number' && trimmed.length > question.maxLength) {
|
|
160
|
-
return
|
|
192
|
+
return { code: 'max_length', params: { max: question.maxLength } };
|
|
161
193
|
}
|
|
162
194
|
if (typeof question.minLength === 'number' && trimmed.length < question.minLength) {
|
|
163
|
-
return
|
|
195
|
+
return { code: 'min_length', params: { min: question.minLength } };
|
|
164
196
|
}
|
|
165
197
|
return null;
|
|
166
198
|
}
|
|
@@ -173,10 +205,10 @@ function validateCheckbox(question, value) {
|
|
|
173
205
|
: []);
|
|
174
206
|
const count = choices.length;
|
|
175
207
|
if (typeof question.minCount === 'number' && count < question.minCount) {
|
|
176
|
-
return
|
|
208
|
+
return { code: 'min_selected', params: { min: question.minCount } };
|
|
177
209
|
}
|
|
178
210
|
if (typeof question.maxCount === 'number' && count > question.maxCount) {
|
|
179
|
-
return
|
|
211
|
+
return { code: 'max_selected', params: { max: question.maxCount } };
|
|
180
212
|
}
|
|
181
213
|
return null;
|
|
182
214
|
}
|
|
@@ -184,10 +216,10 @@ function validateNumericRange(question, value) {
|
|
|
184
216
|
if (typeof value !== 'number')
|
|
185
217
|
return null;
|
|
186
218
|
if (typeof question.minValue === 'number' && value < question.minValue) {
|
|
187
|
-
return
|
|
219
|
+
return { code: 'min_value', params: { min: question.minValue } };
|
|
188
220
|
}
|
|
189
221
|
if (typeof question.maxValue === 'number' && value > question.maxValue) {
|
|
190
|
-
return
|
|
222
|
+
return { code: 'max_value', params: { max: question.maxValue } };
|
|
191
223
|
}
|
|
192
224
|
return null;
|
|
193
225
|
}
|
|
@@ -198,9 +230,14 @@ function validateRules(question, value) {
|
|
|
198
230
|
const str = typeof value === 'string' ? value : String(value ?? '');
|
|
199
231
|
for (const r of rules) {
|
|
200
232
|
const err = applyRule(r, str);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
233
|
+
if (!err)
|
|
234
|
+
continue;
|
|
235
|
+
// The author's own message wins when they wrote one — and it is NOT a code.
|
|
236
|
+
// It is prose the author typed, in whatever language they typed it, so it
|
|
237
|
+
// travels verbatim as a param and is never looked up in a wording table.
|
|
238
|
+
return typeof r.message === 'string' && r.message
|
|
239
|
+
? { code: 'author_message', params: { text: r.message } }
|
|
240
|
+
: err;
|
|
204
241
|
}
|
|
205
242
|
return null;
|
|
206
243
|
}
|
|
@@ -208,20 +245,20 @@ function applyRule(rule, str) {
|
|
|
208
245
|
switch (rule.type) {
|
|
209
246
|
case 'minLength':
|
|
210
247
|
return typeof rule.value === 'number' && str.length < rule.value
|
|
211
|
-
?
|
|
248
|
+
? { code: 'rule_min_length', params: { min: rule.value } } : null;
|
|
212
249
|
case 'maxLength':
|
|
213
250
|
return typeof rule.value === 'number' && str.length > rule.value
|
|
214
|
-
?
|
|
251
|
+
? { code: 'rule_max_length', params: { max: rule.value } } : null;
|
|
215
252
|
case 'min':
|
|
216
253
|
return typeof rule.value === 'number' && Number(str) < rule.value
|
|
217
|
-
?
|
|
254
|
+
? { code: 'min_value', params: { min: rule.value } } : null;
|
|
218
255
|
case 'max':
|
|
219
256
|
return typeof rule.value === 'number' && Number(str) > rule.value
|
|
220
|
-
?
|
|
257
|
+
? { code: 'max_value', params: { max: rule.value } } : null;
|
|
221
258
|
case 'email':
|
|
222
|
-
return EMAIL_RE.test(str) ? null :
|
|
259
|
+
return EMAIL_RE.test(str) ? null : { code: 'rule_email', params: {} };
|
|
223
260
|
case 'url':
|
|
224
|
-
return URL_RE.test(str) ? null : '
|
|
261
|
+
return URL_RE.test(str) ? null : { code: 'rule_url', params: {} };
|
|
225
262
|
case 'pattern': {
|
|
226
263
|
if (typeof rule.value !== 'string')
|
|
227
264
|
return null;
|
|
@@ -229,7 +266,7 @@ function applyRule(rule, str) {
|
|
|
229
266
|
// An unparseable pattern is an authoring mistake. Failing OPEN is
|
|
230
267
|
// deliberate: blocking every respondent on a survey because of a typo
|
|
231
268
|
// in a regex is worse than letting the answer through.
|
|
232
|
-
return new RegExp(rule.value).test(str) ? null :
|
|
269
|
+
return new RegExp(rule.value).test(str) ? null : { code: 'rule_pattern', params: {} };
|
|
233
270
|
}
|
|
234
271
|
catch {
|
|
235
272
|
return null;
|
|
@@ -241,3 +278,519 @@ function applyRule(rule, str) {
|
|
|
241
278
|
return null;
|
|
242
279
|
}
|
|
243
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* An IMPLIED question — the one question a Notify, Offer or Comply
|
|
283
|
+
* interaction asks.
|
|
284
|
+
*
|
|
285
|
+
* There is no input. The respondent's answer is which action they took:
|
|
286
|
+
* "Activate discount" or "No thanks"; "I agree & continue" or "Decline". The
|
|
287
|
+
* choices ARE the buttons, which is why the renderers read their labels from
|
|
288
|
+
* here rather than hardcoding "Next" and "Submit".
|
|
289
|
+
*
|
|
290
|
+
* Required means a choice must be made, and for a Comply interaction that is
|
|
291
|
+
* the whole point: a compliance record with no recorded action is not a
|
|
292
|
+
* record. Dismissing without choosing therefore leaves it unanswered rather
|
|
293
|
+
* than defaulting to the first option — a default here would manufacture
|
|
294
|
+
* agreement nobody gave.
|
|
295
|
+
*/
|
|
296
|
+
function validateImplied(question, value) {
|
|
297
|
+
const required = question.isRequired || question.required;
|
|
298
|
+
const chosen = typeof value === 'string' ? value.trim() : value;
|
|
299
|
+
if (!required)
|
|
300
|
+
return null;
|
|
301
|
+
if (chosen === null || chosen === undefined || chosen === '') {
|
|
302
|
+
return { code: 'choose_option', params: {} };
|
|
303
|
+
}
|
|
304
|
+
// A value that is not one of the offered choices means the answer came from
|
|
305
|
+
// somewhere other than the buttons — a stale client, or a crafted payload.
|
|
306
|
+
const choices = impliedChoices(question);
|
|
307
|
+
if (choices.length && choices.indexOf(String(chosen)) === -1) {
|
|
308
|
+
return { code: 'choose_option', params: {} };
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
/** The values an implied question offers, in order. Its buttons, effectively. */
|
|
313
|
+
function impliedChoices(question) {
|
|
314
|
+
const raw = question.choices
|
|
315
|
+
|| question.options;
|
|
316
|
+
if (!Array.isArray(raw))
|
|
317
|
+
return [];
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const c of raw) {
|
|
320
|
+
if (typeof c === 'string') {
|
|
321
|
+
out.push(c);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (c && typeof c === 'object') {
|
|
325
|
+
const v = c.value;
|
|
326
|
+
const t = c.label ?? c.text;
|
|
327
|
+
const picked = v != null ? v : t;
|
|
328
|
+
if (picked != null && String(picked) !== '')
|
|
329
|
+
out.push(String(picked));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The label a renderer should put on each action button, paired with the value
|
|
336
|
+
* it records. One place, so "Activate discount" reads the same on a website,
|
|
337
|
+
* a link and a phone.
|
|
338
|
+
*/
|
|
339
|
+
function impliedActions(question) {
|
|
340
|
+
const raw = question.choices
|
|
341
|
+
|| question.options;
|
|
342
|
+
if (!Array.isArray(raw))
|
|
343
|
+
return [];
|
|
344
|
+
const out = [];
|
|
345
|
+
for (const c of raw) {
|
|
346
|
+
if (typeof c === 'string') {
|
|
347
|
+
out.push({ value: c, label: c });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (c && typeof c === 'object') {
|
|
351
|
+
const o = c;
|
|
352
|
+
const value = o.value != null ? String(o.value) : String(o.label ?? o.text ?? '');
|
|
353
|
+
const label = String(o.label ?? o.text ?? o.value ?? '');
|
|
354
|
+
// WHERE THE ACTION TAKES THEM. Carried here because it was being dropped:
|
|
355
|
+
// the templates set `action: { type: 'url', value }` on a choice, this
|
|
356
|
+
// function returned only {value, label}, and so a "Resume" button on an
|
|
357
|
+
// abandoned-cart nudge recorded the answer and left the customer exactly
|
|
358
|
+
// where they were — which is the one thing that nudge exists to change.
|
|
359
|
+
const href = safeActionHref(o.action);
|
|
360
|
+
if (value !== '' || label !== '') {
|
|
361
|
+
const entry = href ? { value, label: label || value, href } : { value, label: label || value };
|
|
362
|
+
// "Remind me later" — see isDeferChoice. Carried through because a
|
|
363
|
+
// renderer cannot tell a defer from a decline by looking at the label,
|
|
364
|
+
// and treating one as the other is the difference between a snooze and
|
|
365
|
+
// a permanent no.
|
|
366
|
+
if (o.defer === true)
|
|
367
|
+
entry.defer = true;
|
|
368
|
+
out.push(entry);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Is this choice a "Remind me later" — a deferral rather than an answer?
|
|
376
|
+
*
|
|
377
|
+
* A deferring choice CLOSES the surface AND RECORDS A SKIP, and deliberately
|
|
378
|
+
* does not submit. That distinction is the whole feature: a skip feeds
|
|
379
|
+
* cooldownOnSkip and maxSkips, so the interaction comes back after the wait and
|
|
380
|
+
* gives up after the last try. A submission feeds maxSubmissions, which means
|
|
381
|
+
* "they decided" — and a person who asked to be reminded has decided nothing.
|
|
382
|
+
*
|
|
383
|
+
* Without this, "Remind me later" could only be built as an ordinary choice,
|
|
384
|
+
* which recorded an answer and retired the interaction for good: the one button
|
|
385
|
+
* whose entire purpose is to bring it back was the one that guaranteed it never
|
|
386
|
+
* would.
|
|
387
|
+
*
|
|
388
|
+
* NEVER ON A COMPLIANCE SURFACE, and not as a matter of taste. must-show
|
|
389
|
+
* bypasses the frequency gate entirely while unsatisfied, so a deferral there
|
|
390
|
+
* would be inert — the takeover would reappear immediately and the button would
|
|
391
|
+
* be a visible lie. If someone may genuinely postpone, the interaction is not
|
|
392
|
+
* must-show, and that is the setting to change.
|
|
393
|
+
*/
|
|
394
|
+
function isDeferChoice(choice, interactionType) {
|
|
395
|
+
if (!choice || typeof choice !== 'object')
|
|
396
|
+
return false;
|
|
397
|
+
if (String(interactionType || '').toLowerCase() === 'comply')
|
|
398
|
+
return false;
|
|
399
|
+
return choice.defer === true;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The navigable target of a choice's `action`, or undefined.
|
|
403
|
+
*
|
|
404
|
+
* Only `type: 'url'` navigates today, and only to a scheme that cannot execute
|
|
405
|
+
* script in the host page. `javascript:` and `data:` are the two that can, so
|
|
406
|
+
* an author-supplied (or API-supplied) action is not a place to be permissive:
|
|
407
|
+
* a survey definition travels from the server into a customer's own site, and a
|
|
408
|
+
* URL that runs code there is an XSS vector wearing a button.
|
|
409
|
+
*
|
|
410
|
+
* Relative paths and app deep-link schemes are allowed — a mobile nudge that
|
|
411
|
+
* opens `myapp://cart` is the point of the feature.
|
|
412
|
+
*/
|
|
413
|
+
function safeActionHref(action) {
|
|
414
|
+
if (!action || typeof action !== 'object')
|
|
415
|
+
return undefined;
|
|
416
|
+
const a = action;
|
|
417
|
+
if (String(a.type || '').toLowerCase() !== 'url')
|
|
418
|
+
return undefined;
|
|
419
|
+
const v = typeof a.value === 'string' ? a.value.trim() : '';
|
|
420
|
+
if (!v)
|
|
421
|
+
return undefined; // '' = not configured yet
|
|
422
|
+
// Reject anything whose scheme could execute in the host document.
|
|
423
|
+
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(v);
|
|
424
|
+
if (scheme) {
|
|
425
|
+
const s = scheme[1].toLowerCase();
|
|
426
|
+
if (s === 'javascript' || s === 'data' || s === 'vbscript' || s === 'file')
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
return v;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* The `src` for an image the author placed in a survey, or '' to draw nothing.
|
|
433
|
+
*
|
|
434
|
+
* SEPARATE FROM safeActionHref ON PURPOSE. That one guards a NAVIGATION target,
|
|
435
|
+
* where `data:` is an XSS vector wearing a button — a URL the host page is told
|
|
436
|
+
* to follow. This guards an IMAGE SOURCE, which the host page only ever
|
|
437
|
+
* decodes as pixels, and `data:image/...;base64,` is exactly how the builder
|
|
438
|
+
* stores an uploaded picture: there is no file host in the product, so an
|
|
439
|
+
* upload becomes a data URI or it becomes nothing.
|
|
440
|
+
*
|
|
441
|
+
* Collapsing the two was a real bug, not a hypothetical one. The web SDK ran
|
|
442
|
+
* every image through its link guard, which allows only http/https/mailto/tel,
|
|
443
|
+
* so every uploaded offer image was rewritten to `src=""` and vanished —
|
|
444
|
+
* on the web and mobile preview frames, which both run the SDK, while the link
|
|
445
|
+
* surface (React, no guard) showed it. An author saw their picture appear on
|
|
446
|
+
* one surface out of three and had no way to tell why.
|
|
447
|
+
*
|
|
448
|
+
* `<img>` is a non-scripting context: an SVG loaded through it cannot run its
|
|
449
|
+
* own script, so `data:image/*` is safe here in a way it is not on an href.
|
|
450
|
+
* Everything else with a scheme is still refused — `data:text/html` most of
|
|
451
|
+
* all, which is the one that would actually execute.
|
|
452
|
+
*/
|
|
453
|
+
function safeImageSrc(url) {
|
|
454
|
+
const raw = String(url == null ? '' : url).trim();
|
|
455
|
+
if (!raw)
|
|
456
|
+
return '';
|
|
457
|
+
// Control characters are how a scheme gets smuggled past a naive check
|
|
458
|
+
// ("java\tscript:"), so strip them before looking at it.
|
|
459
|
+
const probe = raw.replace(/[\u0000-\u0020]/g, '');
|
|
460
|
+
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(probe);
|
|
461
|
+
if (!scheme)
|
|
462
|
+
return raw; // relative path — the host resolves it
|
|
463
|
+
const s = scheme[1].toLowerCase();
|
|
464
|
+
if (s === 'http' || s === 'https')
|
|
465
|
+
return raw;
|
|
466
|
+
// Only image payloads, and only ones that declare themselves as such.
|
|
467
|
+
if (s === 'data' && /^data:image\/[a-zA-Z0-9.+-]+[;,]/i.test(probe))
|
|
468
|
+
return raw;
|
|
469
|
+
return '';
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* The action buttons for a STEP, or [] for an ordinary step.
|
|
473
|
+
*
|
|
474
|
+
* A Notify, Offer or Comply interaction's buttons are its ANSWER — "Activate
|
|
475
|
+
* discount" and "No thanks" are the two things the respondent can say, not
|
|
476
|
+
* decoration beside a Next button. So a step that ends in an implied question
|
|
477
|
+
* has NO Next and NO Submit: pressing an action IS the submission.
|
|
478
|
+
*
|
|
479
|
+
* The rule lives here because all three renderers have to agree on it. When
|
|
480
|
+
* they didn't, the web SDK replaced Next with the actions while the React and
|
|
481
|
+
* React Native renderers drew the actions AND kept Submit — the respondent saw
|
|
482
|
+
* "Resume" and "Submit" stacked, and pressing Resume did nothing but tick a
|
|
483
|
+
* radio nobody could see.
|
|
484
|
+
*
|
|
485
|
+
* The LAST question decides. A step is allowed to carry content above the
|
|
486
|
+
* actions (a paragraph of terms, an image); what closes it is what turns it
|
|
487
|
+
* into a decision.
|
|
488
|
+
*
|
|
489
|
+
* Never returns an empty list for an implied step: an implied question with no
|
|
490
|
+
* choices would be a dead end — no buttons and no way out — so one neutral
|
|
491
|
+
* action beats a surface the respondent cannot leave.
|
|
492
|
+
*/
|
|
493
|
+
function impliedStepActions(questions) {
|
|
494
|
+
if (!Array.isArray(questions) || questions.length === 0)
|
|
495
|
+
return [];
|
|
496
|
+
const last = questions[questions.length - 1];
|
|
497
|
+
if (!last || String(last.type || '').toLowerCase() !== 'implied')
|
|
498
|
+
return [];
|
|
499
|
+
try {
|
|
500
|
+
const acts = impliedActions(last) || [];
|
|
501
|
+
return acts.length ? acts : [{ value: 'acknowledged', label: 'Continue' }];
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return [{ value: 'acknowledged', label: 'Continue' }];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Should this step's actions render with EQUAL PROMINENCE?
|
|
509
|
+
*
|
|
510
|
+
* A consent choice is not a call to action with an escape hatch. When accept is
|
|
511
|
+
* a filled accent button and decline is a quiet outline, the design is doing
|
|
512
|
+
* persuasion work on a decision that must be freely given — the pattern
|
|
513
|
+
* regulators name when they talk about consent dark patterns, and the reason
|
|
514
|
+
* `equalProminence` exists on the question.
|
|
515
|
+
*
|
|
516
|
+
* It was being SET by the compliance templates and read by nothing, so both
|
|
517
|
+
* shipped with a filled "I agree" beside a ghosted "Decline".
|
|
518
|
+
*
|
|
519
|
+
* Same "last question decides" rule as impliedStepActions, so the two can never
|
|
520
|
+
* disagree about which question they are talking about.
|
|
521
|
+
*/
|
|
522
|
+
function impliedEqualProminence(questions) {
|
|
523
|
+
if (!Array.isArray(questions) || questions.length === 0)
|
|
524
|
+
return false;
|
|
525
|
+
const last = questions[questions.length - 1];
|
|
526
|
+
if (!last || String(last.type || '').toLowerCase() !== 'implied')
|
|
527
|
+
return false;
|
|
528
|
+
return last.equalProminence === true;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* May this surface be dismissed by SWIPING it away?
|
|
532
|
+
*
|
|
533
|
+
* A push notification on a phone has no ✕. It slides down, and it leaves when
|
|
534
|
+
* you flick it. Removing the close button without providing the gesture would
|
|
535
|
+
* just make it untouchable, so the two decisions are one decision and it lives
|
|
536
|
+
* here rather than three times over.
|
|
537
|
+
*
|
|
538
|
+
* THE FULLSCREEN EXCLUSION IS THE WHOLE POINT. A compliance takeover ALSO sets
|
|
539
|
+
* showClose:false — for the opposite reason: it must not be escapable. Deriving
|
|
540
|
+
* "swipeable" from "has no close button" alone would hand every terms prompt and
|
|
541
|
+
* age check a flick-to-skip gesture, which is precisely the thing that makes it
|
|
542
|
+
* a blocking surface. So a swipe needs a POPUP, and a `mustShow` interaction is
|
|
543
|
+
* never swipeable whatever its mode.
|
|
544
|
+
*
|
|
545
|
+
* @param display The resolved display slice for THIS surface (already picked
|
|
546
|
+
* per platform — a web slice is not a mobile one).
|
|
547
|
+
* @param delivery The survey's delivery policy, for the mustShow veto.
|
|
548
|
+
*/
|
|
549
|
+
function canSwipeToDismiss(display, delivery) {
|
|
550
|
+
if (!display)
|
|
551
|
+
return false;
|
|
552
|
+
if (delivery && delivery.mustShow === true)
|
|
553
|
+
return false;
|
|
554
|
+
const mode = String(display.mode || 'popup').toLowerCase();
|
|
555
|
+
if (mode !== 'popup')
|
|
556
|
+
return false; // fullscreen / modal never swipe
|
|
557
|
+
return display.showClose === false; // the gesture REPLACES the ✕
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* How long before this surface dismisses ITSELF, in ms. 0 = never.
|
|
561
|
+
*
|
|
562
|
+
* A push notification leaves on its own; that is most of what separates it from
|
|
563
|
+
* a dialog. `autoDismiss` was named in the interaction profile as a control the
|
|
564
|
+
* notify purpose leads with, and then existed nowhere else — no field, no UI, no
|
|
565
|
+
* timer — so every notification sat there until someone dealt with it.
|
|
566
|
+
*
|
|
567
|
+
* TWO SURFACES MUST NEVER SELF-DISMISS, and both would be silent disasters:
|
|
568
|
+
*
|
|
569
|
+
* - A LINK IS THE PAGE. Auto-dismissing there does not tidy a banner away, it
|
|
570
|
+
* blanks the page the respondent deliberately opened. The caller passes the
|
|
571
|
+
* resolved slice, so a link slice simply never carries the value — but the
|
|
572
|
+
* mode check below makes it structural rather than a matter of nobody
|
|
573
|
+
* having set it.
|
|
574
|
+
* - A BLOCKING SURFACE. Fullscreen compliance and anything `mustShow` exist to
|
|
575
|
+
* be answered; a timer that clears them is an escape hatch with a stopwatch.
|
|
576
|
+
*
|
|
577
|
+
* Clamped to 2s minimum: a notification that vanishes faster than it can be
|
|
578
|
+
* read is worse than one that never appeared, and it is the accessibility
|
|
579
|
+
* complaint that gets filed about auto-dismissing content.
|
|
580
|
+
*/
|
|
581
|
+
function autoDismissMs(display, delivery) {
|
|
582
|
+
if (!display)
|
|
583
|
+
return 0;
|
|
584
|
+
if (delivery && delivery.mustShow === true)
|
|
585
|
+
return 0;
|
|
586
|
+
if (String(display.mode || 'popup').toLowerCase() !== 'popup')
|
|
587
|
+
return 0;
|
|
588
|
+
const raw = display.autoDismiss;
|
|
589
|
+
const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
|
|
590
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
591
|
+
return 0;
|
|
592
|
+
// Authors think in seconds; anything under 100 is read as seconds.
|
|
593
|
+
const ms = n < 100 ? n * 1000 : n;
|
|
594
|
+
return Math.max(2000, Math.round(ms));
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The countdown to show on an interaction, or null.
|
|
598
|
+
*
|
|
599
|
+
* THE TIMER IS THE DEADLINE — it is not a separate number an author types.
|
|
600
|
+
* That identity is the whole design: the survey's `endDate` is what the
|
|
601
|
+
* eligibility gate already enforces, so a countdown derived from it cannot
|
|
602
|
+
* outlive the thing it counts down to. An independently-authored timer can, and
|
|
603
|
+
* an offer whose clock hits zero while the discount still works (or keeps
|
|
604
|
+
* ticking after it stops) is the expired-deadline dark pattern the validity
|
|
605
|
+
* gate exists to prevent.
|
|
606
|
+
*
|
|
607
|
+
* Returns null when there is no deadline, when it has already passed (the gate
|
|
608
|
+
* stops serving then anyway), or when the surface has not asked to show one.
|
|
609
|
+
* Never invents a deadline.
|
|
610
|
+
*/
|
|
611
|
+
function deadlineCountdown(survey, display, now) {
|
|
612
|
+
if (!survey || !display || display.showDeadline !== true)
|
|
613
|
+
return null;
|
|
614
|
+
const raw = survey.endDate;
|
|
615
|
+
if (!raw)
|
|
616
|
+
return null;
|
|
617
|
+
const end = raw instanceof Date ? raw.getTime() : Date.parse(String(raw));
|
|
618
|
+
if (!Number.isFinite(end))
|
|
619
|
+
return null;
|
|
620
|
+
const ms = end - (typeof now === 'number' ? now : Date.now());
|
|
621
|
+
if (ms <= 0)
|
|
622
|
+
return null; // over — show nothing, not "0:00"
|
|
623
|
+
return { ms, text: formatCountdown(ms) };
|
|
624
|
+
}
|
|
625
|
+
/** "3d 4h" · "4h 12m" · "12m 30s" · "30s" — two units at most, never a wall of zeros. */
|
|
626
|
+
function formatCountdown(ms) {
|
|
627
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
628
|
+
const d = Math.floor(total / 86400);
|
|
629
|
+
const h = Math.floor((total % 86400) / 3600);
|
|
630
|
+
const m = Math.floor((total % 3600) / 60);
|
|
631
|
+
const s = total % 60;
|
|
632
|
+
if (d > 0)
|
|
633
|
+
return h > 0 ? `${d}d ${h}h` : `${d}d`;
|
|
634
|
+
if (h > 0)
|
|
635
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
636
|
+
if (m > 0)
|
|
637
|
+
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
|
638
|
+
return `${s}s`;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Decide the ending.
|
|
642
|
+
*
|
|
643
|
+
* The compliance branch is the one with teeth. Accepting is NOT the same as
|
|
644
|
+
* being cleared: when a human has to review an identity document, letting the
|
|
645
|
+
* customer through on submit tells them they are finished when they are not,
|
|
646
|
+
* and defeats the check. So `reviewRequired` holds the surface up, and a
|
|
647
|
+
* decline holds it too unless the author chose otherwise — a compliance
|
|
648
|
+
* surface you get past by saying no is not a compliance surface.
|
|
649
|
+
*/
|
|
650
|
+
function outcomeFor(input) {
|
|
651
|
+
const purpose = String(input.interactionType || 'ask').toLowerCase();
|
|
652
|
+
const yes = input.affirmative !== false;
|
|
653
|
+
if (purpose === 'notify')
|
|
654
|
+
return yes ? 'navigate' : 'dismiss';
|
|
655
|
+
if (purpose === 'offer') {
|
|
656
|
+
if (!yes)
|
|
657
|
+
return 'dismiss';
|
|
658
|
+
// 'link' sends them somewhere it is already applied; the others have
|
|
659
|
+
// something to show.
|
|
660
|
+
return String(input.reward?.type || '') === 'link' ? 'navigate' : 'fulfil';
|
|
661
|
+
}
|
|
662
|
+
if (purpose === 'comply') {
|
|
663
|
+
if (!yes) {
|
|
664
|
+
return String(input.compliance?.onDecline || 'block') === 'block' ? 'hold' : 'dismiss';
|
|
665
|
+
}
|
|
666
|
+
return input.compliance?.reviewRequired === true ? 'hold' : 'unblock';
|
|
667
|
+
}
|
|
668
|
+
// 'ask' and every customer-defined purpose. A thank-you is the safe default
|
|
669
|
+
// for something we do not recognise: it acknowledges without promising.
|
|
670
|
+
return 'thanks';
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Can voice mode do anything on this step?
|
|
674
|
+
*
|
|
675
|
+
* A notification, an offer and a compliance takeover ask one question whose
|
|
676
|
+
* answer is which button you pressed. A microphone on that surface offers to
|
|
677
|
+
* transcribe nothing — it was showing up on offers because `voiceMode` was
|
|
678
|
+
* merely DEFAULTED off on newer templates, so any interaction created before
|
|
679
|
+
* that, or through the API, still rendered one.
|
|
680
|
+
*
|
|
681
|
+
* Structural, not a default: if the step has no answerable input, voice is off
|
|
682
|
+
* regardless of what the survey or the application says.
|
|
683
|
+
*/
|
|
684
|
+
function voiceUsable(questions) {
|
|
685
|
+
if (!Array.isArray(questions) || questions.length === 0)
|
|
686
|
+
return false;
|
|
687
|
+
return questions.some((q) => {
|
|
688
|
+
const t = String(q.type || '').toLowerCase();
|
|
689
|
+
// Display-only types answer nothing; implied is answered by pressing.
|
|
690
|
+
return t !== 'implied' && t !== 'html' && t !== 'content'
|
|
691
|
+
&& t !== 'image' && t !== 'expression' && t !== 'pagebreak';
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
/** The reward, ready to show — or null when there is nothing to hand over. */
|
|
695
|
+
function rewardToShow(survey) {
|
|
696
|
+
const r = survey?.reward;
|
|
697
|
+
if (!r || !r.type)
|
|
698
|
+
return null;
|
|
699
|
+
const type = String(r.type);
|
|
700
|
+
const value = r.value == null ? '' : String(r.value);
|
|
701
|
+
// 'automatic' has nothing to display — the host app applies it on the
|
|
702
|
+
// outcome — so there is no panel to render.
|
|
703
|
+
if (type === 'automatic')
|
|
704
|
+
return null;
|
|
705
|
+
if (!value)
|
|
706
|
+
return null;
|
|
707
|
+
return {
|
|
708
|
+
type,
|
|
709
|
+
value,
|
|
710
|
+
whereValid: r.whereValid == null ? '' : String(r.whereValid),
|
|
711
|
+
// THE INTERACTION'S OWN END DATE. Not a second field that could disagree
|
|
712
|
+
// with the countdown the customer just watched.
|
|
713
|
+
expiresAt: survey?.endDate ? new Date(String(survey.endDate)).toISOString() : null,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Where clicking the CARD ITSELF should take someone, or null.
|
|
718
|
+
*
|
|
719
|
+
* People tap the message, not the button — so a notification's whole card is
|
|
720
|
+
* the click target. This was derived behaviour ("one action that has a link");
|
|
721
|
+
* it is now something an author sets, with the derived rule as the default so
|
|
722
|
+
* nothing that already worked stops working.
|
|
723
|
+
*
|
|
724
|
+
* THE TWO-ACTION VETO IS ABSOLUTE and not an author's to override. On a card
|
|
725
|
+
* offering accept and decline, a stray click anywhere would have to mean one of
|
|
726
|
+
* them — and on a consent surface that would manufacture agreement from a
|
|
727
|
+
* mis-tap. So a card is only ever clickable when it asks for exactly one thing.
|
|
728
|
+
*/
|
|
729
|
+
function cardClickTarget(display, actions) {
|
|
730
|
+
const acts = Array.isArray(actions) ? actions : [];
|
|
731
|
+
if (acts.length !== 1)
|
|
732
|
+
return null; // never on a choice
|
|
733
|
+
if (display?.cardClickable === false)
|
|
734
|
+
return null; // explicitly switched off
|
|
735
|
+
// An author-set destination wins, and goes through the same scheme guard as
|
|
736
|
+
// any other action target — this navigates inside the customer's own page.
|
|
737
|
+
const own = safeActionHref({ type: 'url', value: display?.cardHref });
|
|
738
|
+
return own || acts[0].href || null;
|
|
739
|
+
}
|
|
740
|
+
/** Hover treatment for a clickable card. Pointer devices only. */
|
|
741
|
+
function cardHoverEffect(display) {
|
|
742
|
+
const v = String(display?.hoverEffect || '').toLowerCase();
|
|
743
|
+
return v === 'lift' || v === 'glow' || v === 'tint' ? v : 'none';
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Resolve an action against a variable context.
|
|
747
|
+
*
|
|
748
|
+
* `interpolate` is supplied by the caller because each renderer already owns
|
|
749
|
+
* one, with its own context — the web SDK's reads the trigger event and device
|
|
750
|
+
* signals, the link renderer's reads URL params and answers. Passing the
|
|
751
|
+
* function keeps one rule here and one context there.
|
|
752
|
+
*
|
|
753
|
+
* INTERPOLATE FIRST, THEN CHECK THE SCHEME. A variable is data from outside —
|
|
754
|
+
* a URL parameter, a trait, an event field — so validating the template and
|
|
755
|
+
* then substituting would let `{{next}}` smuggle in `javascript:`. safeActionHref
|
|
756
|
+
* runs on the FINISHED string.
|
|
757
|
+
*/
|
|
758
|
+
function resolveAction(action, interpolate = (s) => s) {
|
|
759
|
+
if (!action || typeof action !== 'object')
|
|
760
|
+
return null;
|
|
761
|
+
const raw = String(action.type || 'url').toLowerCase();
|
|
762
|
+
const kind = raw === 'event' || raw === 'webhook' ? raw : 'url';
|
|
763
|
+
const template = typeof action.value === 'string' ? action.value : '';
|
|
764
|
+
const substituted = template ? interpolate(template) : '';
|
|
765
|
+
const href = substituted ? safeActionHref({ type: 'url', value: substituted }) || null : null;
|
|
766
|
+
const payload = {};
|
|
767
|
+
const p = action.payload;
|
|
768
|
+
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
|
769
|
+
for (const [k, v] of Object.entries(p)) {
|
|
770
|
+
if (!k)
|
|
771
|
+
continue;
|
|
772
|
+
const tpl = String(v == null ? '' : v);
|
|
773
|
+
const out = interpolate(tpl);
|
|
774
|
+
// A reference that did not resolve is DROPPED, never sent.
|
|
775
|
+
//
|
|
776
|
+
// Two shapes of the same problem. Some interpolators leave the token in
|
|
777
|
+
// place, and "{{eventData.orderId}}" arriving at a CRM is indistinguishable
|
|
778
|
+
// from a real id, so it gets stored as one. Others — the web SDK's among
|
|
779
|
+
// them — substitute an empty string, which is worse: the field looks
|
|
780
|
+
// answered and says nothing. Since we cannot tell "resolved to empty"
|
|
781
|
+
// from "never resolved", a template that produces nothing is omitted, and
|
|
782
|
+
// the receiving system sees an absent field rather than a false one.
|
|
783
|
+
if (/\{\{.*\}\}/.test(out))
|
|
784
|
+
continue;
|
|
785
|
+
if (!out && /\{\{.*\}\}/.test(tpl))
|
|
786
|
+
continue;
|
|
787
|
+
payload[k] = out;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const eventName = typeof action.eventName === 'string' && action.eventName.trim()
|
|
791
|
+
? action.eventName.trim() : null;
|
|
792
|
+
// Nothing to do: no destination, no event name, no payload.
|
|
793
|
+
if (!href && !eventName && Object.keys(payload).length === 0)
|
|
794
|
+
return null;
|
|
795
|
+
return { kind, href, eventName, payload };
|
|
796
|
+
}
|