@reform-society/agera-core 0.1.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/index.d.ts +385 -0
- package/dist/index.js +1122 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import { atom } from "nanostores";
|
|
2
|
+
import log from "loglevel";
|
|
3
|
+
//#region src/stores.ts
|
|
4
|
+
function createSessionStore(initial) {
|
|
5
|
+
return atom({
|
|
6
|
+
id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`,
|
|
7
|
+
answers: {},
|
|
8
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
9
|
+
...initial
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function createStepsStore(initial) {
|
|
13
|
+
return atom({
|
|
14
|
+
current: "",
|
|
15
|
+
path: [],
|
|
16
|
+
...initial
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/resolve.ts
|
|
21
|
+
function traverseDotPath(obj, dotPath) {
|
|
22
|
+
const parts = dotPath.split(".");
|
|
23
|
+
let current = obj;
|
|
24
|
+
for (const part of parts) {
|
|
25
|
+
if (current == null || typeof current !== "object") return void 0;
|
|
26
|
+
current = current[part];
|
|
27
|
+
}
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
function resolveQuizPath(dotPath, quizResults) {
|
|
31
|
+
const directValue = traverseDotPath(quizResults, dotPath);
|
|
32
|
+
if (directValue !== void 0) return directValue;
|
|
33
|
+
if (dotPath === "winner") return traverseDotPath(quizResults, "weighted.winner");
|
|
34
|
+
if (dotPath === "winner.score") return quizResults.winnerScore ?? traverseDotPath(quizResults, "weighted.winnerScore");
|
|
35
|
+
if (dotPath.startsWith("scores.")) return traverseDotPath(quizResults, `weighted.${dotPath}`);
|
|
36
|
+
if (dotPath.startsWith("weighted.")) return traverseDotPath(quizResults, dotPath.slice(9));
|
|
37
|
+
}
|
|
38
|
+
function resolveValue(field, answers, sources) {
|
|
39
|
+
if (field.startsWith("storage.")) {
|
|
40
|
+
const key = field.slice(8);
|
|
41
|
+
return sources?.storage?.[key];
|
|
42
|
+
}
|
|
43
|
+
if (field.startsWith("crm_response.")) {
|
|
44
|
+
const dotPath = field.slice(13);
|
|
45
|
+
if (!sources?.crmResponse) return void 0;
|
|
46
|
+
return traverseDotPath(sources.crmResponse, dotPath);
|
|
47
|
+
}
|
|
48
|
+
if (field === "env.device") return sources?.env?.device;
|
|
49
|
+
if (field.startsWith("env.")) {
|
|
50
|
+
const path = field.slice(4).split(".");
|
|
51
|
+
if (path[0] === "utm" && path[1]) {
|
|
52
|
+
const fromEnv = sources?.env?.utm?.[path[1]];
|
|
53
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
54
|
+
return (sources?.queryParams ?? new URLSearchParams()).get(`utm_${path[1]}`);
|
|
55
|
+
}
|
|
56
|
+
if (path[0]) return answers[path[0]];
|
|
57
|
+
}
|
|
58
|
+
if (field.startsWith("query.")) {
|
|
59
|
+
const paramName = field.slice(6);
|
|
60
|
+
return (sources?.queryParams ?? new URLSearchParams()).get(paramName);
|
|
61
|
+
}
|
|
62
|
+
if (field.startsWith("quiz.")) {
|
|
63
|
+
const quizResults = answers["__quiz__"];
|
|
64
|
+
if (!quizResults) return void 0;
|
|
65
|
+
return resolveQuizPath(field.slice(5), quizResults);
|
|
66
|
+
}
|
|
67
|
+
return answers[field];
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/answers.ts
|
|
71
|
+
const PRESERVED_ANSWER_KEYS = new Set(["__quiz__"]);
|
|
72
|
+
function mergeAnswers(previous, dom, preserveKeys) {
|
|
73
|
+
const merged = { ...dom };
|
|
74
|
+
const keysToPreserve = preserveKeys ?? PRESERVED_ANSWER_KEYS;
|
|
75
|
+
for (const [key, value] of Object.entries(previous)) if (!(key in merged) || keysToPreserve.has(key)) merged[key] = value;
|
|
76
|
+
return merged;
|
|
77
|
+
}
|
|
78
|
+
function answersChanged(previous, next) {
|
|
79
|
+
const prevKeys = Object.keys(previous);
|
|
80
|
+
const nextKeys = Object.keys(next);
|
|
81
|
+
if (prevKeys.length !== nextKeys.length) return true;
|
|
82
|
+
for (const key of nextKeys) if (previous[key] !== next[key]) return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/predicates.ts
|
|
87
|
+
function evaluatePredicate(predicate, answers, sources) {
|
|
88
|
+
if ("all" in predicate) return predicate.all.every((p) => evaluatePredicate(p, answers, sources));
|
|
89
|
+
if ("any" in predicate) return predicate.any.some((p) => evaluatePredicate(p, answers, sources));
|
|
90
|
+
if ("not" in predicate) return !evaluatePredicate(predicate.not, answers, sources);
|
|
91
|
+
if ("field" in predicate && "op" in predicate) {
|
|
92
|
+
const { field, op, value } = predicate;
|
|
93
|
+
const fieldValue = resolveValue(field, answers, sources);
|
|
94
|
+
switch (op) {
|
|
95
|
+
case "eq": return fieldValue === value;
|
|
96
|
+
case "neq": return fieldValue !== value;
|
|
97
|
+
case "in": return Array.isArray(value) && value.includes(fieldValue);
|
|
98
|
+
case "nin": return Array.isArray(value) && !value.includes(fieldValue);
|
|
99
|
+
case "gt": return Number(fieldValue) > Number(value);
|
|
100
|
+
case "gte": return Number(fieldValue) >= Number(value);
|
|
101
|
+
case "lt": return Number(fieldValue) < Number(value);
|
|
102
|
+
case "lte": return Number(fieldValue) <= Number(value);
|
|
103
|
+
case "exists": return fieldValue !== void 0 && fieldValue !== null;
|
|
104
|
+
case "truthy": return Boolean(fieldValue);
|
|
105
|
+
case "falsy": return !fieldValue;
|
|
106
|
+
case "contains": return String(fieldValue).includes(String(value));
|
|
107
|
+
case "starts_with": return String(fieldValue).startsWith(String(value));
|
|
108
|
+
case "ends_with": return String(fieldValue).endsWith(String(value));
|
|
109
|
+
default: return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
function evaluateShowIf(predicateJson, answers, sources) {
|
|
115
|
+
try {
|
|
116
|
+
return evaluatePredicate(JSON.parse(predicateJson), answers, sources);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
log.error("[conditions] Invalid predicate JSON:", error);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function evaluateNextMap(nextMapJson, answers, sources) {
|
|
123
|
+
try {
|
|
124
|
+
const nextMap = JSON.parse(nextMapJson);
|
|
125
|
+
for (const rule of nextMap) if (evaluatePredicate(rule.if, answers, sources)) return rule.then;
|
|
126
|
+
return null;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
log.error("[conditions] Invalid next-map JSON:", error);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/validators.ts
|
|
134
|
+
/**
|
|
135
|
+
* Parse value as number if it looks numeric, otherwise return null for length comparison.
|
|
136
|
+
*/
|
|
137
|
+
function parseNumericValue(val) {
|
|
138
|
+
const trimmed = val.trim();
|
|
139
|
+
if (trimmed === "") return null;
|
|
140
|
+
const num = Number(trimmed);
|
|
141
|
+
return !isNaN(num) && isFinite(num) ? num : null;
|
|
142
|
+
}
|
|
143
|
+
/** Must start with a letter (Unicode), allows letters, hyphens, apostrophes, dots, spaces */
|
|
144
|
+
const NAME_RE = /^[\p{L}\p{M}][\p{L}\p{M}'\-.\s]*$/u;
|
|
145
|
+
const validators = {
|
|
146
|
+
required: (val) => val.trim() !== "",
|
|
147
|
+
email: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
|
|
148
|
+
name: (val) => NAME_RE.test(val.trim()),
|
|
149
|
+
pattern: (val, re) => {
|
|
150
|
+
try {
|
|
151
|
+
return new RegExp(re).test(val);
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
min: (val, n) => {
|
|
157
|
+
const num = parseNumericValue(val);
|
|
158
|
+
const threshold = Number(n);
|
|
159
|
+
return num !== null ? num >= threshold : val.length >= threshold;
|
|
160
|
+
},
|
|
161
|
+
max: (val, n) => {
|
|
162
|
+
const num = parseNumericValue(val);
|
|
163
|
+
const threshold = Number(n);
|
|
164
|
+
return num !== null ? num <= threshold : val.length <= threshold;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/validation-messages.ts
|
|
169
|
+
const errorMessages = {
|
|
170
|
+
"sv-SE": {
|
|
171
|
+
required: "Fyll i det här fältet för att gå vidare",
|
|
172
|
+
email: "Skriv en e-postadress i formatet namn@exempel.se",
|
|
173
|
+
name: "Kontrollera att namnet är rättstavat",
|
|
174
|
+
phone: "Ange ett giltigt telefonnummer, till exempel {example}",
|
|
175
|
+
pattern: "Formatet ser inte rätt ut, kontrollera en gång till",
|
|
176
|
+
min: "Skriv minst {min} tecken",
|
|
177
|
+
max: "Skriv högst {max} tecken",
|
|
178
|
+
minValue: "Ange ett värde på minst {minValue}",
|
|
179
|
+
maxValue: "Ange ett värde på högst {maxValue}"
|
|
180
|
+
},
|
|
181
|
+
"nb-NO": {
|
|
182
|
+
required: "Fyll ut dette feltet for å gå videre",
|
|
183
|
+
email: "Skriv inn en e-postadresse i formatet navn@eksempel.no",
|
|
184
|
+
name: "Kontroller at navnet er riktig stavet",
|
|
185
|
+
phone: "Oppgi et gyldig telefonnummer, for eksempel {example}",
|
|
186
|
+
pattern: "Formatet ser ikke riktig ut, sjekk en gang til",
|
|
187
|
+
min: "Skriv minst {min} tegn",
|
|
188
|
+
max: "Skriv maks {max} tegn",
|
|
189
|
+
minValue: "Oppgi en verdi på minst {minValue}",
|
|
190
|
+
maxValue: "Oppgi en verdi på maks {maxValue}"
|
|
191
|
+
},
|
|
192
|
+
"da-DK": {
|
|
193
|
+
required: "Udfyld feltet for at gå videre",
|
|
194
|
+
email: "Skriv en e-mailadresse i formatet navn@eksempel.dk",
|
|
195
|
+
name: "Kontroller at navnet er stavet korrekt",
|
|
196
|
+
phone: "Angiv et gyldigt telefonnummer, for eksempel {example}",
|
|
197
|
+
pattern: "Formatet ser ikke rigtigt ud, tjek en ekstra gang",
|
|
198
|
+
min: "Skriv mindst {min} tegn",
|
|
199
|
+
max: "Skriv højst {max} tegn",
|
|
200
|
+
minValue: "Angiv en værdi på mindst {minValue}",
|
|
201
|
+
maxValue: "Angiv en værdi på højst {maxValue}"
|
|
202
|
+
},
|
|
203
|
+
"en-US": {
|
|
204
|
+
required: "Fill in this field to continue",
|
|
205
|
+
email: "Enter an email address in the format name@example.com",
|
|
206
|
+
name: "Check that the name is spelled correctly",
|
|
207
|
+
phone: "Enter a valid phone number, for example {example}",
|
|
208
|
+
pattern: "The format does not look right, please check again",
|
|
209
|
+
min: "Enter at least {min} characters",
|
|
210
|
+
max: "Enter no more than {max} characters",
|
|
211
|
+
minValue: "Enter a value of at least {minValue}",
|
|
212
|
+
maxValue: "Enter a value of at most {maxValue}"
|
|
213
|
+
},
|
|
214
|
+
en: {
|
|
215
|
+
required: "Fill in this field to continue",
|
|
216
|
+
email: "Enter an email address in the format name@example.com",
|
|
217
|
+
name: "Check that the name is spelled correctly",
|
|
218
|
+
phone: "Enter a valid phone number, for example {example}",
|
|
219
|
+
pattern: "The format does not look right, please check again",
|
|
220
|
+
min: "Enter at least {min} characters",
|
|
221
|
+
max: "Enter no more than {max} characters",
|
|
222
|
+
minValue: "Enter a value of at least {minValue}",
|
|
223
|
+
maxValue: "Enter a value of at most {maxValue}"
|
|
224
|
+
},
|
|
225
|
+
"de-DE": {
|
|
226
|
+
required: "Dieses Feld ausfüllen, um fortzufahren",
|
|
227
|
+
email: "Eine E-Mail-Adresse im Format name@beispiel.de eingeben",
|
|
228
|
+
name: "Prüfen, ob der Name richtig geschrieben ist",
|
|
229
|
+
phone: "Geben Sie eine gültige Telefonnummer ein, zum Beispiel {example}",
|
|
230
|
+
pattern: "Das Format sieht nicht richtig aus, bitte noch einmal prüfen",
|
|
231
|
+
min: "Mindestens {min} Zeichen eingeben",
|
|
232
|
+
max: "Höchstens {max} Zeichen eingeben",
|
|
233
|
+
minValue: "Einen Wert von mindestens {minValue} eingeben",
|
|
234
|
+
maxValue: "Einen Wert von höchstens {maxValue} eingeben"
|
|
235
|
+
},
|
|
236
|
+
"es-ES": {
|
|
237
|
+
required: "Rellena este campo para continuar",
|
|
238
|
+
email: "Escribe un correo en formato nombre@ejemplo.es",
|
|
239
|
+
name: "Comprueba que el nombre esté bien escrito",
|
|
240
|
+
phone: "Introduce un número de teléfono válido, por ejemplo {example}",
|
|
241
|
+
pattern: "El formato no parece correcto, revísalo de nuevo",
|
|
242
|
+
min: "Escribe al menos {min} caracteres",
|
|
243
|
+
max: "Escribe como máximo {max} caracteres",
|
|
244
|
+
minValue: "Indica un valor de al menos {minValue}",
|
|
245
|
+
maxValue: "Indica un valor de como máximo {maxValue}"
|
|
246
|
+
},
|
|
247
|
+
"fi-FI": {
|
|
248
|
+
required: "Täytä tämä kenttä jatkaaksesi",
|
|
249
|
+
email: "Kirjoita sähköpostiosoite muodossa nimi@esimerkki.fi",
|
|
250
|
+
name: "Tarkista, että nimi on kirjoitettu oikein",
|
|
251
|
+
phone: "Syötä kelvollinen puhelinnumero, esimerkiksi {example}",
|
|
252
|
+
pattern: "Muoto ei näytä oikealta, tarkista vielä kerran",
|
|
253
|
+
min: "Kirjoita vähintään {min} merkkiä",
|
|
254
|
+
max: "Kirjoita enintään {max} merkkiä",
|
|
255
|
+
minValue: "Anna arvo, joka on vähintään {minValue}",
|
|
256
|
+
maxValue: "Anna arvo, joka on enintään {maxValue}"
|
|
257
|
+
},
|
|
258
|
+
"fr-FR": {
|
|
259
|
+
required: "Renseignez ce champ pour continuer",
|
|
260
|
+
email: "Saisissez une adresse e-mail au format nom@exemple.fr",
|
|
261
|
+
name: "Vérifiez que le nom est correctement orthographié",
|
|
262
|
+
phone: "Saisissez un numéro de téléphone valide, par exemple {example}",
|
|
263
|
+
pattern: "Le format ne semble pas correct, vérifiez une nouvelle fois",
|
|
264
|
+
min: "Saisissez au moins {min} caractères",
|
|
265
|
+
max: "Saisissez au plus {max} caractères",
|
|
266
|
+
minValue: "Saisissez une valeur d'au moins {minValue}",
|
|
267
|
+
maxValue: "Saisissez une valeur d'au plus {maxValue}"
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
errorMessages.sv = errorMessages["sv-SE"];
|
|
271
|
+
errorMessages.nb = errorMessages["nb-NO"];
|
|
272
|
+
errorMessages.no = errorMessages["nb-NO"];
|
|
273
|
+
errorMessages.da = errorMessages["da-DK"];
|
|
274
|
+
errorMessages.de = errorMessages["de-DE"];
|
|
275
|
+
errorMessages.es = errorMessages["es-ES"];
|
|
276
|
+
errorMessages.fi = errorMessages["fi-FI"];
|
|
277
|
+
errorMessages.fr = errorMessages["fr-FR"];
|
|
278
|
+
const PHONE_EXAMPLES = {
|
|
279
|
+
SE: {
|
|
280
|
+
national: "070-123 45 67",
|
|
281
|
+
international: "+46 70 123 45 67",
|
|
282
|
+
e164: "+46701234567"
|
|
283
|
+
},
|
|
284
|
+
NO: {
|
|
285
|
+
national: "912 34 567",
|
|
286
|
+
international: "+47 912 34 567",
|
|
287
|
+
e164: "+4791234567"
|
|
288
|
+
},
|
|
289
|
+
DK: {
|
|
290
|
+
national: "20 12 34 56",
|
|
291
|
+
international: "+45 20 12 34 56",
|
|
292
|
+
e164: "+4520123456"
|
|
293
|
+
},
|
|
294
|
+
US: {
|
|
295
|
+
national: "(555) 123-4567",
|
|
296
|
+
international: "+1 555 123 4567",
|
|
297
|
+
e164: "+15551234567"
|
|
298
|
+
},
|
|
299
|
+
GB: {
|
|
300
|
+
national: "07911 123456",
|
|
301
|
+
international: "+44 7911 123456",
|
|
302
|
+
e164: "+447911123456"
|
|
303
|
+
},
|
|
304
|
+
DE: {
|
|
305
|
+
national: "0151 12345678",
|
|
306
|
+
international: "+49 151 12345678",
|
|
307
|
+
e164: "+4915112345678"
|
|
308
|
+
},
|
|
309
|
+
ES: {
|
|
310
|
+
national: "612 34 56 78",
|
|
311
|
+
international: "+34 612 34 56 78",
|
|
312
|
+
e164: "+34612345678"
|
|
313
|
+
},
|
|
314
|
+
FI: {
|
|
315
|
+
national: "040 1234567",
|
|
316
|
+
international: "+358 40 1234567",
|
|
317
|
+
e164: "+358401234567"
|
|
318
|
+
},
|
|
319
|
+
FR: {
|
|
320
|
+
national: "06 12 34 56 78",
|
|
321
|
+
international: "+33 6 12 34 56 78",
|
|
322
|
+
e164: "+33612345678"
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
function getPhoneExample(region, format) {
|
|
326
|
+
const key = region?.toUpperCase();
|
|
327
|
+
const fmt = format?.toUpperCase();
|
|
328
|
+
const selected = (key ? PHONE_EXAMPLES[key] : void 0) ?? {
|
|
329
|
+
national: "070-123 45 67",
|
|
330
|
+
international: "+46 70 123 45 67",
|
|
331
|
+
e164: "+46701234567"
|
|
332
|
+
};
|
|
333
|
+
if (fmt === "E164") return selected.e164;
|
|
334
|
+
if (fmt === "NATIONAL") return selected.national;
|
|
335
|
+
return selected.international;
|
|
336
|
+
}
|
|
337
|
+
const placesMessages = {
|
|
338
|
+
"sv-SE": {
|
|
339
|
+
hint: "Välj gata, fyll sedan i husnummer",
|
|
340
|
+
noResults: "Inga adresser hittades",
|
|
341
|
+
validate: "Välj en adress från listan",
|
|
342
|
+
manual: "Fyll i adress manuellt",
|
|
343
|
+
backToSearch: "Tillbaka till sökningen",
|
|
344
|
+
manualValidate: "Fyll i alla adressfält",
|
|
345
|
+
postalErr: "Postnummer måste följa formatet {example}"
|
|
346
|
+
},
|
|
347
|
+
"nb-NO": {
|
|
348
|
+
hint: "Velg gate, fyll deretter inn husnummer",
|
|
349
|
+
noResults: "Ingen adresser funnet",
|
|
350
|
+
validate: "Velg en adresse fra listen",
|
|
351
|
+
manual: "Fyll inn adresse manuelt",
|
|
352
|
+
backToSearch: "Tilbake til søk",
|
|
353
|
+
manualValidate: "Fyll inn alle adressefelt",
|
|
354
|
+
postalErr: "Postnummer må følge formatet {example}"
|
|
355
|
+
},
|
|
356
|
+
"da-DK": {
|
|
357
|
+
hint: "Vælg vej, og udfyld derefter husnummer",
|
|
358
|
+
noResults: "Ingen adresser fundet",
|
|
359
|
+
validate: "Vælg en adresse fra listen",
|
|
360
|
+
manual: "Indtast adresse manuelt",
|
|
361
|
+
backToSearch: "Tilbage til søgning",
|
|
362
|
+
manualValidate: "Udfyld alle adressefelter",
|
|
363
|
+
postalErr: "Postnummer skal have formatet {example}"
|
|
364
|
+
},
|
|
365
|
+
"en-US": {
|
|
366
|
+
hint: "Select street, then add house number",
|
|
367
|
+
noResults: "No addresses found",
|
|
368
|
+
validate: "Select an address from the list",
|
|
369
|
+
manual: "Enter address manually",
|
|
370
|
+
backToSearch: "Back to search",
|
|
371
|
+
manualValidate: "Fill in all address fields",
|
|
372
|
+
postalErr: "Postal code must match format {example}"
|
|
373
|
+
},
|
|
374
|
+
en: {
|
|
375
|
+
hint: "Select street, then add house number",
|
|
376
|
+
noResults: "No addresses found",
|
|
377
|
+
validate: "Select an address from the list",
|
|
378
|
+
manual: "Enter address manually",
|
|
379
|
+
backToSearch: "Back to search",
|
|
380
|
+
manualValidate: "Fill in all address fields",
|
|
381
|
+
postalErr: "Postal code must match format {example}"
|
|
382
|
+
},
|
|
383
|
+
"de-DE": {
|
|
384
|
+
hint: "Straße auswählen, dann Hausnummer ergänzen",
|
|
385
|
+
noResults: "Keine Adressen gefunden",
|
|
386
|
+
validate: "Wählen Sie eine Adresse aus der Liste",
|
|
387
|
+
manual: "Adresse manuell eingeben",
|
|
388
|
+
backToSearch: "Zurück zur Suche",
|
|
389
|
+
manualValidate: "Füllen Sie alle Adressfelder aus",
|
|
390
|
+
postalErr: "Postleitzahl muss dem Format {example} entsprechen"
|
|
391
|
+
},
|
|
392
|
+
"es-ES": {
|
|
393
|
+
hint: "Selecciona calle y luego añade número",
|
|
394
|
+
noResults: "No se encontraron direcciones",
|
|
395
|
+
validate: "Selecciona una dirección de la lista",
|
|
396
|
+
manual: "Introducir dirección manualmente",
|
|
397
|
+
backToSearch: "Volver a la búsqueda",
|
|
398
|
+
manualValidate: "Rellena todos los campos de dirección",
|
|
399
|
+
postalErr: "El código postal debe tener el formato {example}"
|
|
400
|
+
},
|
|
401
|
+
"fi-FI": {
|
|
402
|
+
hint: "Valitse katu ja lisää sitten talon numero",
|
|
403
|
+
noResults: "Osoitteita ei löytynyt",
|
|
404
|
+
validate: "Valitse osoite listalta",
|
|
405
|
+
manual: "Syötä osoite manuaalisesti",
|
|
406
|
+
backToSearch: "Takaisin hakuun",
|
|
407
|
+
manualValidate: "Täytä kaikki osoitekentät",
|
|
408
|
+
postalErr: "Postinumeron pitää olla muodossa {example}"
|
|
409
|
+
},
|
|
410
|
+
"fr-FR": {
|
|
411
|
+
hint: "Sélectionnez la rue, puis ajoutez le numéro",
|
|
412
|
+
noResults: "Aucune adresse trouvée",
|
|
413
|
+
validate: "Sélectionnez une adresse dans la liste",
|
|
414
|
+
manual: "Saisir l'adresse manuellement",
|
|
415
|
+
backToSearch: "Retour à la recherche",
|
|
416
|
+
manualValidate: "Renseignez tous les champs d'adresse",
|
|
417
|
+
postalErr: "Le code postal doit suivre le format {example}"
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
placesMessages.sv = placesMessages["sv-SE"];
|
|
421
|
+
placesMessages.nb = placesMessages["nb-NO"];
|
|
422
|
+
placesMessages.no = placesMessages["nb-NO"];
|
|
423
|
+
placesMessages.da = placesMessages["da-DK"];
|
|
424
|
+
placesMessages.de = placesMessages["de-DE"];
|
|
425
|
+
placesMessages.es = placesMessages["es-ES"];
|
|
426
|
+
placesMessages.fi = placesMessages["fi-FI"];
|
|
427
|
+
placesMessages.fr = placesMessages["fr-FR"];
|
|
428
|
+
const POSTAL_EXAMPLES = {
|
|
429
|
+
SE: "123 45",
|
|
430
|
+
NO: "0123",
|
|
431
|
+
DK: "1234",
|
|
432
|
+
US: "12345 or 12345-6789",
|
|
433
|
+
GB: "SW1A 1AA",
|
|
434
|
+
DE: "10115",
|
|
435
|
+
ES: "28013",
|
|
436
|
+
FI: "00100",
|
|
437
|
+
FR: "75001"
|
|
438
|
+
};
|
|
439
|
+
function getPostalExample(region) {
|
|
440
|
+
const key = region?.toUpperCase();
|
|
441
|
+
if (!key) return POSTAL_EXAMPLES.US;
|
|
442
|
+
return POSTAL_EXAMPLES[key] ?? POSTAL_EXAMPLES.US;
|
|
443
|
+
}
|
|
444
|
+
function getPlacesMessagesForLanguage(lang, region) {
|
|
445
|
+
const messages = (!lang ? void 0 : placesMessages[lang] ?? placesMessages[lang.split("-")[0]]) ?? placesMessages.en;
|
|
446
|
+
return {
|
|
447
|
+
...messages,
|
|
448
|
+
postalErr: messages.postalErr.replace("{example}", getPostalExample(region))
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Gets error messages for a given language tag.
|
|
453
|
+
* Falls back to English if language is not found.
|
|
454
|
+
*/
|
|
455
|
+
function getErrorMessagesForLanguage(lang) {
|
|
456
|
+
if (!lang) return errorMessages.en;
|
|
457
|
+
return errorMessages[lang] ?? errorMessages[lang.split("-")[0]] ?? errorMessages.en;
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/quiz.ts
|
|
461
|
+
function uniqueStrings(values) {
|
|
462
|
+
const seen = /* @__PURE__ */ new Set();
|
|
463
|
+
return values.filter((value) => {
|
|
464
|
+
if (seen.has(value)) return false;
|
|
465
|
+
seen.add(value);
|
|
466
|
+
return true;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
function normalizeWeightedScores(scores) {
|
|
470
|
+
const normalized = {};
|
|
471
|
+
for (const [category, weight] of Object.entries(scores)) if (Number.isFinite(weight) && weight > 0) normalized[category] = weight;
|
|
472
|
+
return normalized;
|
|
473
|
+
}
|
|
474
|
+
function normalizeQuestionInput(question) {
|
|
475
|
+
return {
|
|
476
|
+
...question,
|
|
477
|
+
answeredValues: uniqueStrings(question.answeredValues),
|
|
478
|
+
answeredLabels: uniqueStrings(question.answeredLabels),
|
|
479
|
+
expectedValues: question.expectedValues ? uniqueStrings(question.expectedValues) : null,
|
|
480
|
+
expectedLabels: question.expectedLabels ? uniqueStrings(question.expectedLabels) : null,
|
|
481
|
+
weightedScores: normalizeWeightedScores(question.weightedScores)
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function areSameAnswerSet(left, right) {
|
|
485
|
+
if (left.length !== right.length) return false;
|
|
486
|
+
const rightSet = new Set(right);
|
|
487
|
+
return left.every((value) => rightSet.has(value));
|
|
488
|
+
}
|
|
489
|
+
function isSupportedCorrectnessQuestion(question) {
|
|
490
|
+
if (question.invalid || question.scoringModel == null || question.expectedValues == null) return false;
|
|
491
|
+
if (question.scoringModel === "partial") return question.model === "multi_select" && question.expectedValues.length > 0;
|
|
492
|
+
return question.expectedValues.length > 0;
|
|
493
|
+
}
|
|
494
|
+
function getEligibleQuestionResults(questionResults) {
|
|
495
|
+
return Object.values(questionResults).filter((question) => question.eligible);
|
|
496
|
+
}
|
|
497
|
+
function getUniqueScoringModels(questions) {
|
|
498
|
+
const active = /* @__PURE__ */ new Set();
|
|
499
|
+
for (const question of questions) {
|
|
500
|
+
if (question.usesWeightedScoring) active.add("weighted");
|
|
501
|
+
if (question.scoringModel != null && isSupportedCorrectnessQuestion(question)) active.add(question.scoringModel);
|
|
502
|
+
}
|
|
503
|
+
return Array.from(active);
|
|
504
|
+
}
|
|
505
|
+
function buildQuestionResult(question, earnedPoints, possiblePoints) {
|
|
506
|
+
return {
|
|
507
|
+
key: question.key,
|
|
508
|
+
model: question.model,
|
|
509
|
+
section: question.section,
|
|
510
|
+
eligible: question.eligible,
|
|
511
|
+
answeredValues: question.answeredValues,
|
|
512
|
+
answeredLabels: question.answeredLabels,
|
|
513
|
+
expectedValues: question.expectedValues,
|
|
514
|
+
expectedLabels: question.expectedLabels,
|
|
515
|
+
state: deriveQuestionState(question.answeredValues, earnedPoints, possiblePoints),
|
|
516
|
+
earnedPoints,
|
|
517
|
+
possiblePoints
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Calculate the winner from scores, handling ties
|
|
522
|
+
*/
|
|
523
|
+
function calculateWinner(scores) {
|
|
524
|
+
const categories = Object.keys(scores);
|
|
525
|
+
if (categories.length === 0) return {
|
|
526
|
+
scores: {},
|
|
527
|
+
winner: null,
|
|
528
|
+
winnerScore: 0,
|
|
529
|
+
ties: []
|
|
530
|
+
};
|
|
531
|
+
const maxScore = Math.max(...Object.values(scores));
|
|
532
|
+
const winners = categories.filter((cat) => scores[cat] === maxScore);
|
|
533
|
+
if (winners.length === 1) return {
|
|
534
|
+
scores,
|
|
535
|
+
winner: winners[0],
|
|
536
|
+
winnerScore: maxScore,
|
|
537
|
+
ties: []
|
|
538
|
+
};
|
|
539
|
+
return {
|
|
540
|
+
scores,
|
|
541
|
+
winner: winners[0],
|
|
542
|
+
winnerScore: maxScore,
|
|
543
|
+
ties: winners
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function deriveQuestionState(answeredValues, earnedPoints, possiblePoints) {
|
|
547
|
+
if (answeredValues.length === 0) return "unanswered";
|
|
548
|
+
if (possiblePoints <= 0 || earnedPoints <= 0) return "incorrect";
|
|
549
|
+
if (earnedPoints >= possiblePoints) return "correct";
|
|
550
|
+
return "partial";
|
|
551
|
+
}
|
|
552
|
+
function scoreExactQuestion(question) {
|
|
553
|
+
const normalizedQuestion = normalizeQuestionInput(question);
|
|
554
|
+
if (normalizedQuestion.invalid || normalizedQuestion.scoringModel !== "exact" || normalizedQuestion.expectedValues == null || normalizedQuestion.expectedValues.length === 0) return null;
|
|
555
|
+
return buildQuestionResult(normalizedQuestion, normalizedQuestion.answeredValues.length > 0 && areSameAnswerSet(normalizedQuestion.answeredValues, normalizedQuestion.expectedValues) ? 1 : 0, 1);
|
|
556
|
+
}
|
|
557
|
+
function scorePartialQuestion(question) {
|
|
558
|
+
const normalizedQuestion = normalizeQuestionInput(question);
|
|
559
|
+
if (normalizedQuestion.invalid || normalizedQuestion.scoringModel !== "partial" || normalizedQuestion.model !== "multi_select" || normalizedQuestion.expectedValues == null || normalizedQuestion.expectedValues.length === 0) return null;
|
|
560
|
+
const expectedValues = new Set(normalizedQuestion.expectedValues);
|
|
561
|
+
const correctSelections = normalizedQuestion.answeredValues.filter((value) => expectedValues.has(value)).length;
|
|
562
|
+
const incorrectSelections = normalizedQuestion.answeredValues.filter((value) => !expectedValues.has(value)).length;
|
|
563
|
+
const pointShare = 1 / normalizedQuestion.expectedValues.length;
|
|
564
|
+
return buildQuestionResult(normalizedQuestion, Math.max(0, Math.min(1, correctSelections * pointShare - incorrectSelections * pointShare)), 1);
|
|
565
|
+
}
|
|
566
|
+
function scoreQuizQuestions(questions) {
|
|
567
|
+
const questionResults = {};
|
|
568
|
+
for (const question of questions.map(normalizeQuestionInput)) {
|
|
569
|
+
if (!isSupportedCorrectnessQuestion(question)) continue;
|
|
570
|
+
const result = question.scoringModel === "partial" ? scorePartialQuestion(question) : scoreExactQuestion(question);
|
|
571
|
+
if (result) questionResults[result.key] = result;
|
|
572
|
+
}
|
|
573
|
+
return questionResults;
|
|
574
|
+
}
|
|
575
|
+
function calculateScoreResults(questionResults) {
|
|
576
|
+
const eligibleQuestions = questionResults.filter((question) => question.eligible);
|
|
577
|
+
if (eligibleQuestions.length === 0) return null;
|
|
578
|
+
const earnedPoints = eligibleQuestions.reduce((total, question) => total + question.earnedPoints, 0);
|
|
579
|
+
const possiblePoints = eligibleQuestions.reduce((total, question) => total + question.possiblePoints, 0);
|
|
580
|
+
if (possiblePoints === 0) return null;
|
|
581
|
+
return {
|
|
582
|
+
earnedPoints,
|
|
583
|
+
possiblePoints,
|
|
584
|
+
percent: Math.round(earnedPoints / possiblePoints * 100),
|
|
585
|
+
eligibleCount: eligibleQuestions.length,
|
|
586
|
+
correctCount: eligibleQuestions.filter((question) => question.state === "correct").length,
|
|
587
|
+
partialCount: eligibleQuestions.filter((question) => question.state === "partial").length,
|
|
588
|
+
incorrectCount: eligibleQuestions.filter((question) => question.state === "incorrect").length,
|
|
589
|
+
unansweredCount: eligibleQuestions.filter((question) => question.state === "unanswered").length
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function aggregateWeightedScores(questions) {
|
|
593
|
+
const eligibleWeightedQuestions = questions.map(normalizeQuestionInput).filter((question) => !question.invalid && question.eligible && question.usesWeightedScoring);
|
|
594
|
+
if (eligibleWeightedQuestions.length === 0) return null;
|
|
595
|
+
const scores = {};
|
|
596
|
+
for (const question of eligibleWeightedQuestions) for (const [category, weight] of Object.entries(question.weightedScores)) scores[category] = (scores[category] ?? 0) + weight;
|
|
597
|
+
return calculateWinner(scores);
|
|
598
|
+
}
|
|
599
|
+
function derivePassResults(scoreResults, config) {
|
|
600
|
+
if (!scoreResults || !config) return null;
|
|
601
|
+
const passed = scoreResults.percent >= config.percentGte;
|
|
602
|
+
return {
|
|
603
|
+
passed,
|
|
604
|
+
label: passed ? "pass" : "fail"
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function deriveBandResults(scoreResults, bands) {
|
|
608
|
+
if (!scoreResults || !bands || bands.length === 0) return null;
|
|
609
|
+
const match = bands.find((band) => {
|
|
610
|
+
const meetsMin = band.minPercent == null || scoreResults.percent >= band.minPercent;
|
|
611
|
+
const meetsMax = band.maxPercent == null || scoreResults.percent <= band.maxPercent;
|
|
612
|
+
return meetsMin && meetsMax;
|
|
613
|
+
});
|
|
614
|
+
return {
|
|
615
|
+
id: match?.id ?? null,
|
|
616
|
+
label: match?.label ?? null
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function buildOutcomeBundle(questions, questionResults, config) {
|
|
620
|
+
const score = calculateScoreResults(getEligibleQuestionResults(questionResults));
|
|
621
|
+
return {
|
|
622
|
+
score,
|
|
623
|
+
weighted: aggregateWeightedScores(questions),
|
|
624
|
+
pass: derivePassResults(score, config.passFail),
|
|
625
|
+
band: deriveBandResults(score, config.bands)
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function calculateSectionResults(questions, questionResults, config = {}) {
|
|
629
|
+
const sections = {};
|
|
630
|
+
const sectionIds = Array.from(new Set(questions.filter((question) => !question.invalid && question.section != null).map((question) => question.section)));
|
|
631
|
+
for (const sectionId of sectionIds) {
|
|
632
|
+
const sectionQuestions = questions.filter((question) => !question.invalid && question.section === sectionId);
|
|
633
|
+
const sectionQuestionResults = Object.fromEntries(Object.entries(questionResults).filter(([, question]) => question.section === sectionId));
|
|
634
|
+
const sectionConfig = config.sections?.[sectionId];
|
|
635
|
+
sections[sectionId] = buildOutcomeBundle(sectionQuestions, sectionQuestionResults, {
|
|
636
|
+
passFail: sectionConfig?.passFail ?? config.passFail,
|
|
637
|
+
bands: sectionConfig?.bands ?? config.bands
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return sections;
|
|
641
|
+
}
|
|
642
|
+
function scoreQuiz(questions, config = {}) {
|
|
643
|
+
const normalizedQuestions = questions.map(normalizeQuestionInput).filter((question) => !question.invalid);
|
|
644
|
+
const questionResults = scoreQuizQuestions(normalizedQuestions);
|
|
645
|
+
const { score, weighted, pass, band } = buildOutcomeBundle(normalizedQuestions, questionResults, config);
|
|
646
|
+
const sections = calculateSectionResults(normalizedQuestions, questionResults, config);
|
|
647
|
+
return {
|
|
648
|
+
activeQuestionModels: Array.from(new Set(normalizedQuestions.filter((question) => question.eligible).map((question) => question.model))),
|
|
649
|
+
activeScoringModels: getUniqueScoringModels(normalizedQuestions.filter((question) => question.eligible)),
|
|
650
|
+
activeOutcomes: [
|
|
651
|
+
...score ? ["score"] : [],
|
|
652
|
+
...weighted ? ["weighted"] : [],
|
|
653
|
+
...pass ? ["pass"] : [],
|
|
654
|
+
...band ? ["band"] : [],
|
|
655
|
+
...Object.keys(sections).length > 0 ? ["sections"] : []
|
|
656
|
+
],
|
|
657
|
+
questions: questionResults,
|
|
658
|
+
score,
|
|
659
|
+
weighted,
|
|
660
|
+
pass,
|
|
661
|
+
band,
|
|
662
|
+
sections
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region src/basin-client.ts
|
|
667
|
+
/** Check if JWT is expired (with 60s buffer) */
|
|
668
|
+
function isJwtExpired(jwt) {
|
|
669
|
+
try {
|
|
670
|
+
const payload = jwt.split(".")[1];
|
|
671
|
+
if (!payload) return true;
|
|
672
|
+
const { exp } = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
|
673
|
+
return typeof exp === "number" && exp <= Date.now() / 1e3 + 60;
|
|
674
|
+
} catch {
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async function fetchBasinJWT(endpoint, storage, storageKey) {
|
|
679
|
+
const cached = storage.getItem(storageKey);
|
|
680
|
+
if (cached && !isJwtExpired(cached)) return cached;
|
|
681
|
+
if (cached) storage.removeItem(storageKey);
|
|
682
|
+
try {
|
|
683
|
+
const res = await fetch(`${endpoint}/generate_jwt`, {
|
|
684
|
+
method: "GET",
|
|
685
|
+
headers: {
|
|
686
|
+
"Content-Type": "application/json",
|
|
687
|
+
Accept: "application/json"
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
if (!res.ok) {
|
|
691
|
+
log.warn("[basin] JWT endpoint returned", res.status, "- multistep may not be supported");
|
|
692
|
+
return "";
|
|
693
|
+
}
|
|
694
|
+
const data = await res.json();
|
|
695
|
+
if (data.jwt) {
|
|
696
|
+
storage.setItem(storageKey, data.jwt);
|
|
697
|
+
return data.jwt;
|
|
698
|
+
}
|
|
699
|
+
return "";
|
|
700
|
+
} catch (error) {
|
|
701
|
+
log.warn("[basin] Failed to fetch JWT (multistep may not be supported):", error);
|
|
702
|
+
return "";
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/utm.ts
|
|
707
|
+
const UTM_KEYS = [
|
|
708
|
+
"source",
|
|
709
|
+
"campaign",
|
|
710
|
+
"medium",
|
|
711
|
+
"content",
|
|
712
|
+
"term",
|
|
713
|
+
"wec"
|
|
714
|
+
];
|
|
715
|
+
function extractUtms(search) {
|
|
716
|
+
const params = new URLSearchParams(search);
|
|
717
|
+
const utms = {};
|
|
718
|
+
for (const key of UTM_KEYS) {
|
|
719
|
+
const matchingKey = [...params.keys()].find((k) => {
|
|
720
|
+
const lower = k.toLowerCase();
|
|
721
|
+
return lower === key || lower.endsWith(`_${key}`) || lower.endsWith(`-${key}`);
|
|
722
|
+
});
|
|
723
|
+
if (matchingKey) {
|
|
724
|
+
const val = params.get(matchingKey)?.toLowerCase();
|
|
725
|
+
if (val) utms[key] = val;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return utms;
|
|
729
|
+
}
|
|
730
|
+
function buildFcrmUtms(utms) {
|
|
731
|
+
const result = {};
|
|
732
|
+
for (const key of UTM_KEYS) if (utms[key]) result[`utm_${key}`] = utms[key];
|
|
733
|
+
return result;
|
|
734
|
+
}
|
|
735
|
+
//#endregion
|
|
736
|
+
//#region src/url-utils.ts
|
|
737
|
+
/** Appends query params to a URL without overriding existing ones. */
|
|
738
|
+
function updateUrlWithParams(url, params, base) {
|
|
739
|
+
function appendParamsToParsedUrl(parsed) {
|
|
740
|
+
params.forEach((value, param) => {
|
|
741
|
+
if (!parsed.searchParams.has(param)) parsed.searchParams.set(param, value);
|
|
742
|
+
});
|
|
743
|
+
return parsed.toString();
|
|
744
|
+
}
|
|
745
|
+
function appendParamsToRelativeUrl(relativeUrl) {
|
|
746
|
+
const hashIndex = relativeUrl.indexOf("#");
|
|
747
|
+
const beforeHash = hashIndex >= 0 ? relativeUrl.slice(0, hashIndex) : relativeUrl;
|
|
748
|
+
const hash = hashIndex >= 0 ? relativeUrl.slice(hashIndex) : "";
|
|
749
|
+
const queryIndex = beforeHash.indexOf("?");
|
|
750
|
+
const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
|
|
751
|
+
const query = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : "";
|
|
752
|
+
const searchParams = new URLSearchParams(query);
|
|
753
|
+
params.forEach((value, param) => {
|
|
754
|
+
if (!searchParams.has(param)) searchParams.set(param, value);
|
|
755
|
+
});
|
|
756
|
+
const nextQuery = searchParams.toString();
|
|
757
|
+
return nextQuery ? `${path}?${nextQuery}${hash}` : `${path}${hash}`;
|
|
758
|
+
}
|
|
759
|
+
function isAbsoluteLikeUrl(value) {
|
|
760
|
+
const trimmed = value.trim();
|
|
761
|
+
return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed) || trimmed.startsWith("//");
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
if (base) return appendParamsToParsedUrl(new URL(url, base));
|
|
765
|
+
try {
|
|
766
|
+
return appendParamsToParsedUrl(new URL(url));
|
|
767
|
+
} catch {
|
|
768
|
+
if (isAbsoluteLikeUrl(url)) return url;
|
|
769
|
+
return appendParamsToRelativeUrl(url);
|
|
770
|
+
}
|
|
771
|
+
} catch {
|
|
772
|
+
return url;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region src/memory-cache.ts
|
|
777
|
+
function createMemoryCache(options = {}) {
|
|
778
|
+
const entries = /* @__PURE__ */ new Map();
|
|
779
|
+
const max = options.max ?? 0;
|
|
780
|
+
const defaultTtlMs = options.ttlMs ?? 0;
|
|
781
|
+
const now = options.now ?? Date.now;
|
|
782
|
+
function isExpired(entry) {
|
|
783
|
+
return entry.expiresAt != null && entry.expiresAt <= now();
|
|
784
|
+
}
|
|
785
|
+
function touch(key, entry) {
|
|
786
|
+
entries.delete(key);
|
|
787
|
+
entries.set(key, entry);
|
|
788
|
+
}
|
|
789
|
+
function pruneExpired() {
|
|
790
|
+
for (const [key, entry] of entries) {
|
|
791
|
+
if (!isExpired(entry)) continue;
|
|
792
|
+
entries.delete(key);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function enforceMaxSize() {
|
|
796
|
+
if (max <= 0) return;
|
|
797
|
+
while (entries.size > max) {
|
|
798
|
+
const oldestKey = entries.keys().next().value;
|
|
799
|
+
if (oldestKey === void 0) return;
|
|
800
|
+
entries.delete(oldestKey);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function resolveExpiresAt(ttlMs) {
|
|
804
|
+
const effectiveTtlMs = ttlMs ?? defaultTtlMs;
|
|
805
|
+
if (effectiveTtlMs <= 0) return null;
|
|
806
|
+
return now() + effectiveTtlMs;
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
get(key) {
|
|
810
|
+
const entry = entries.get(key);
|
|
811
|
+
if (!entry) return void 0;
|
|
812
|
+
if (isExpired(entry)) {
|
|
813
|
+
entries.delete(key);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
touch(key, entry);
|
|
817
|
+
return entry.value;
|
|
818
|
+
},
|
|
819
|
+
set(key, value, setOptions = {}) {
|
|
820
|
+
pruneExpired();
|
|
821
|
+
touch(key, {
|
|
822
|
+
value,
|
|
823
|
+
expiresAt: resolveExpiresAt(setOptions.ttlMs)
|
|
824
|
+
});
|
|
825
|
+
enforceMaxSize();
|
|
826
|
+
return value;
|
|
827
|
+
},
|
|
828
|
+
has(key) {
|
|
829
|
+
const entry = entries.get(key);
|
|
830
|
+
if (!entry) return false;
|
|
831
|
+
if (isExpired(entry)) {
|
|
832
|
+
entries.delete(key);
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
return true;
|
|
836
|
+
},
|
|
837
|
+
delete(key) {
|
|
838
|
+
return entries.delete(key);
|
|
839
|
+
},
|
|
840
|
+
clear() {
|
|
841
|
+
entries.clear();
|
|
842
|
+
},
|
|
843
|
+
size() {
|
|
844
|
+
pruneExpired();
|
|
845
|
+
return entries.size;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/flatten.ts
|
|
851
|
+
function flattenObject(obj, prefix = "") {
|
|
852
|
+
const result = {};
|
|
853
|
+
for (const key of Object.keys(obj)) {
|
|
854
|
+
const value = obj[key];
|
|
855
|
+
const flatKey = prefix ? `${prefix}.${key}` : key;
|
|
856
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) Object.assign(result, flattenObject(value, flatKey));
|
|
857
|
+
else result[flatKey] = value;
|
|
858
|
+
}
|
|
859
|
+
return result;
|
|
860
|
+
}
|
|
861
|
+
//#endregion
|
|
862
|
+
//#region src/counter.ts
|
|
863
|
+
/** djb2 hash → 6-char hex string */
|
|
864
|
+
function djb2(str) {
|
|
865
|
+
let hash = 5381;
|
|
866
|
+
for (let i = 0; i < str.length; i++) hash = hash * 33 ^ str.charCodeAt(i);
|
|
867
|
+
return (hash >>> 0).toString(16).slice(0, 6);
|
|
868
|
+
}
|
|
869
|
+
function inferCounterName(client, pathname) {
|
|
870
|
+
return `${client}-${djb2(pathname)}`;
|
|
871
|
+
}
|
|
872
|
+
function parseCounterUpdateAttr(value, client, pathname) {
|
|
873
|
+
if (!value || value === "false") return [];
|
|
874
|
+
if (value === "true" || value === "auto") return [{ name: inferCounterName(client, pathname) }];
|
|
875
|
+
const trimmed = value.trim();
|
|
876
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) try {
|
|
877
|
+
const parsed = JSON.parse(trimmed);
|
|
878
|
+
return (Array.isArray(parsed) ? parsed : [parsed]).filter((item) => typeof item === "object" && item !== null && typeof item.name === "string");
|
|
879
|
+
} catch (err) {
|
|
880
|
+
log.warn("[counter] Failed to parse counter update config:", value, err);
|
|
881
|
+
return [];
|
|
882
|
+
}
|
|
883
|
+
return [{ name: trimmed }];
|
|
884
|
+
}
|
|
885
|
+
function buildCounterPayload(config, context) {
|
|
886
|
+
const body = {};
|
|
887
|
+
if (config.add_amount != null) body.add_amount = config.add_amount;
|
|
888
|
+
if (config.add_amount_field) body.add_amount_field = config.add_amount_field;
|
|
889
|
+
if (context?.source_url) body.source = context.source_url;
|
|
890
|
+
return {
|
|
891
|
+
name: config.name,
|
|
892
|
+
body
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/counter-display.ts
|
|
897
|
+
const TIERS = [
|
|
898
|
+
{
|
|
899
|
+
threshold: 5e5,
|
|
900
|
+
step: 1e5
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
threshold: 2e5,
|
|
904
|
+
step: 5e4
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
threshold: 1e5,
|
|
908
|
+
step: 25e3
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
threshold: 5e4,
|
|
912
|
+
step: 1e4
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
threshold: 2e4,
|
|
916
|
+
step: 5e3
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
threshold: 5e3,
|
|
920
|
+
step: 2500
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
threshold: 2e3,
|
|
924
|
+
step: 500
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
threshold: 500,
|
|
928
|
+
step: 250
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
threshold: 350,
|
|
932
|
+
step: 100
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
threshold: 0,
|
|
936
|
+
step: 50
|
|
937
|
+
}
|
|
938
|
+
];
|
|
939
|
+
function computeAutoTarget(current) {
|
|
940
|
+
const raw = current * 1.05;
|
|
941
|
+
const step = TIERS.find((t) => raw >= t.threshold)?.step ?? 50;
|
|
942
|
+
return Math.ceil(raw / step) * step;
|
|
943
|
+
}
|
|
944
|
+
function formatCounterValue(value, locale, notation, useGrouping) {
|
|
945
|
+
try {
|
|
946
|
+
return new Intl.NumberFormat(locale ?? "en", {
|
|
947
|
+
notation: notation ?? "standard",
|
|
948
|
+
useGrouping: useGrouping ?? true
|
|
949
|
+
}).format(value);
|
|
950
|
+
} catch {
|
|
951
|
+
return String(value);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
//#endregion
|
|
955
|
+
//#region src/insert.ts
|
|
956
|
+
function parseInsertDirective(value) {
|
|
957
|
+
if (!value) return null;
|
|
958
|
+
const colonIndex = value.indexOf(":");
|
|
959
|
+
if (colonIndex <= 0 || colonIndex === value.length - 1) return null;
|
|
960
|
+
return {
|
|
961
|
+
source: value.slice(0, colonIndex),
|
|
962
|
+
key: value.slice(colonIndex + 1)
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function parseInsertDirectives(value) {
|
|
966
|
+
if (!value) return [];
|
|
967
|
+
return value.split("|").map((segment) => parseInsertDirective(segment.trim())).filter((d) => d !== null);
|
|
968
|
+
}
|
|
969
|
+
//#endregion
|
|
970
|
+
//#region src/crm-context.ts
|
|
971
|
+
function buildContext(config, frontendKeys, counters) {
|
|
972
|
+
const ctx = {};
|
|
973
|
+
for (const [key, value] of Object.entries(config)) if (!frontendKeys.has(key) && value !== void 0) ctx[key] = typeof value === "number" || typeof value === "boolean" ? String(value) : value;
|
|
974
|
+
if (counters && counters.length > 0) ctx.counters = counters;
|
|
975
|
+
return ctx;
|
|
976
|
+
}
|
|
977
|
+
//#endregion
|
|
978
|
+
//#region src/crm-utils.ts
|
|
979
|
+
const BLOCKED_REDIRECT_PROTOCOLS = /^(javascript|data|vbscript):/i;
|
|
980
|
+
function isCounterResponse(data) {
|
|
981
|
+
return typeof data.count === "number" && typeof data.name === "string";
|
|
982
|
+
}
|
|
983
|
+
function isSafeRedirectUrl(url) {
|
|
984
|
+
const trimmed = url.trim();
|
|
985
|
+
if (!trimmed) return false;
|
|
986
|
+
if (trimmed.startsWith("//")) return false;
|
|
987
|
+
if (BLOCKED_REDIRECT_PROTOCOLS.test(trimmed)) return false;
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/counter-fetch.ts
|
|
992
|
+
const FALLBACK = (name) => ({
|
|
993
|
+
count: 0,
|
|
994
|
+
name
|
|
995
|
+
});
|
|
996
|
+
async function fetchCounter(name, siteId, apiBaseUrl, sourceUrl) {
|
|
997
|
+
const url = `${apiBaseUrl}counter/${encodeURIComponent(name)}?site_id=${encodeURIComponent(siteId)}`;
|
|
998
|
+
let res;
|
|
999
|
+
try {
|
|
1000
|
+
res = await fetch(url, { headers: { "x-source": sourceUrl } });
|
|
1001
|
+
} catch {
|
|
1002
|
+
return FALLBACK(name);
|
|
1003
|
+
}
|
|
1004
|
+
if (!res.ok) return FALLBACK(name);
|
|
1005
|
+
let data;
|
|
1006
|
+
try {
|
|
1007
|
+
data = await res.json();
|
|
1008
|
+
} catch {
|
|
1009
|
+
return FALLBACK(name);
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
count: Number(data.count) || 0,
|
|
1013
|
+
name
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region src/device.ts
|
|
1018
|
+
function detectDeviceType(input) {
|
|
1019
|
+
const { userAgent: ua = "", maxTouchPoints: maxTouch = 0, screenWidth, uaDataMobile } = input;
|
|
1020
|
+
if (uaDataMobile === true) return "mobile";
|
|
1021
|
+
if (maxTouch > 0 && /iPad|Macintosh/i.test(ua) && !/iPhone/i.test(ua)) return "tablet";
|
|
1022
|
+
if (maxTouch > 0 && /Android/i.test(ua) && !/Mobile/i.test(ua)) return "tablet";
|
|
1023
|
+
if (/iPhone|iPod|Android.*Mobile|webOS|BlackBerry|Opera Mini|IEMobile/i.test(ua)) return "mobile";
|
|
1024
|
+
if (maxTouch > 0) {
|
|
1025
|
+
const width = screenWidth ?? 1920;
|
|
1026
|
+
if (width <= 480) return "mobile";
|
|
1027
|
+
if (width <= 1024) return "tablet";
|
|
1028
|
+
}
|
|
1029
|
+
return "desktop";
|
|
1030
|
+
}
|
|
1031
|
+
//#endregion
|
|
1032
|
+
//#region src/crm-fetch.ts
|
|
1033
|
+
const noop = () => {};
|
|
1034
|
+
async function crmFetch(url, payload, options = {}) {
|
|
1035
|
+
const { waitForResponse = true, sourceUrl, deviceType, method = "POST" } = options;
|
|
1036
|
+
const body = JSON.stringify(payload);
|
|
1037
|
+
const headers = { "Content-Type": "application/json" };
|
|
1038
|
+
if (sourceUrl) headers["x-source"] = sourceUrl;
|
|
1039
|
+
if (deviceType !== void 0) headers["x-device-type"] = deviceType;
|
|
1040
|
+
if (!waitForResponse) {
|
|
1041
|
+
try {
|
|
1042
|
+
fetch(url, {
|
|
1043
|
+
method,
|
|
1044
|
+
headers,
|
|
1045
|
+
body,
|
|
1046
|
+
keepalive: true
|
|
1047
|
+
}).catch(noop);
|
|
1048
|
+
} catch {}
|
|
1049
|
+
return {
|
|
1050
|
+
ok: true,
|
|
1051
|
+
status: 0,
|
|
1052
|
+
data: null
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
try {
|
|
1056
|
+
const response = await fetch(url, {
|
|
1057
|
+
method,
|
|
1058
|
+
headers,
|
|
1059
|
+
body
|
|
1060
|
+
});
|
|
1061
|
+
let data = null;
|
|
1062
|
+
try {
|
|
1063
|
+
data = await response.json();
|
|
1064
|
+
} catch {}
|
|
1065
|
+
return {
|
|
1066
|
+
ok: response.ok,
|
|
1067
|
+
status: response.status,
|
|
1068
|
+
data
|
|
1069
|
+
};
|
|
1070
|
+
} catch {
|
|
1071
|
+
return {
|
|
1072
|
+
ok: false,
|
|
1073
|
+
status: 0,
|
|
1074
|
+
data: null
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region src/crm-request.ts
|
|
1080
|
+
function assembleCrmRequest({ body: rawBody, config, frontendKeys, utms, queryParams, sourceUrl, counters, jsonGroups }) {
|
|
1081
|
+
const configuredCounters = Array.isArray(config.counter_update) ? config.counter_update : void 0;
|
|
1082
|
+
const resolvedCounters = counters ?? configuredCounters;
|
|
1083
|
+
const body = { ...rawBody };
|
|
1084
|
+
if (jsonGroups) {
|
|
1085
|
+
const groupEntries = Object.entries(jsonGroups);
|
|
1086
|
+
if (groupEntries.length > 0) body["json-groups"] = JSON.stringify(groupEntries.map(([key, fields]) => ({ [key]: fields })));
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
body,
|
|
1090
|
+
source: sourceUrl,
|
|
1091
|
+
utms: buildFcrmUtms(utms),
|
|
1092
|
+
query_params: queryParams,
|
|
1093
|
+
context: buildContext(config, frontendKeys, resolvedCounters)
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
//#endregion
|
|
1097
|
+
//#region src/polling.ts
|
|
1098
|
+
function isRecord(value) {
|
|
1099
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1100
|
+
}
|
|
1101
|
+
function matchesFormat(example, value) {
|
|
1102
|
+
if (example === null || example === void 0) return value === example;
|
|
1103
|
+
if (value === null || value === void 0) return false;
|
|
1104
|
+
if (typeof example !== typeof value) return false;
|
|
1105
|
+
if (Array.isArray(example)) {
|
|
1106
|
+
if (!Array.isArray(value)) return false;
|
|
1107
|
+
if (example.length === 0) return true;
|
|
1108
|
+
if (example.length === 1) return value.every((entry) => matchesFormat(example[0], entry));
|
|
1109
|
+
if (value.length < example.length) return false;
|
|
1110
|
+
return example.every((entry, index) => matchesFormat(entry, value[index]));
|
|
1111
|
+
}
|
|
1112
|
+
if (isRecord(example)) {
|
|
1113
|
+
if (!isRecord(value)) return false;
|
|
1114
|
+
return Object.entries(example).every(([key, nestedExample]) => key in value && matchesFormat(nestedExample, value[key]));
|
|
1115
|
+
}
|
|
1116
|
+
if (example === "") return typeof value === "string";
|
|
1117
|
+
return example === value;
|
|
1118
|
+
}
|
|
1119
|
+
//#endregion
|
|
1120
|
+
export { aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createMemoryCache, createSessionStore, createStepsStore, crmFetch, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, extractUtms, fetchBasinJWT, fetchCounter, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, inferCounterName, isCounterResponse, isJwtExpired, isSafeRedirectUrl, matchesFormat, mergeAnswers, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, updateUrlWithParams, validators };
|
|
1121
|
+
|
|
1122
|
+
//# sourceMappingURL=index.js.map
|