@reform-society/agera-core 0.6.0 → 0.7.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 +191 -1
- package/dist/index.js +763 -1
- package/package.json +4 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as _$nanostores from "nanostores";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
|
|
3
4
|
//#region src/types.d.ts
|
|
4
5
|
type PredicateOp = "eq" | "neq" | "in" | "nin" | "gt" | "gte" | "lt" | "lte" | "exists" | "truthy" | "falsy" | "contains" | "starts_with" | "ends_with";
|
|
@@ -218,6 +219,148 @@ type FetchBasinJWTOptions = {
|
|
|
218
219
|
};
|
|
219
220
|
declare function fetchBasinJWT(endpoint: string, storage: StorageAccessor, storageKey: string, options?: FetchBasinJWTOptions): Promise<string>;
|
|
220
221
|
//#endregion
|
|
222
|
+
//#region src/survey/types.d.ts
|
|
223
|
+
type NavAction = {
|
|
224
|
+
kind: "next";
|
|
225
|
+
} | {
|
|
226
|
+
kind: "goto";
|
|
227
|
+
target: string;
|
|
228
|
+
} | {
|
|
229
|
+
kind: "redirect";
|
|
230
|
+
target: string;
|
|
231
|
+
};
|
|
232
|
+
type SurveyFieldRule = "required" | "email" | "name" | "phone" | "postal" | {
|
|
233
|
+
rule: "pattern" | "min" | "max";
|
|
234
|
+
arg: string;
|
|
235
|
+
};
|
|
236
|
+
type SurveyStepRule = {
|
|
237
|
+
rule: "required";
|
|
238
|
+
} | {
|
|
239
|
+
rule: "minSelected" | "maxSelected";
|
|
240
|
+
arg: number;
|
|
241
|
+
};
|
|
242
|
+
type SurveyOption = {
|
|
243
|
+
value: string;
|
|
244
|
+
label?: string;
|
|
245
|
+
nav?: NavAction; /** Quiz: this option is a correct answer (feeds `expectedValues`). */
|
|
246
|
+
correct?: boolean; /** Quiz: weighted scoring contributions when selected (feeds `weightedScores`). */
|
|
247
|
+
weights?: Record<string, number>; /** Free-text "other" option; the typed text is stored under `<name>_other`. */
|
|
248
|
+
other?: boolean;
|
|
249
|
+
};
|
|
250
|
+
type SurveyField = {
|
|
251
|
+
name: string;
|
|
252
|
+
type: "text" | "email" | "tel" | "textarea" | "postal" | "address";
|
|
253
|
+
rules?: SurveyFieldRule[];
|
|
254
|
+
};
|
|
255
|
+
type SurveyStepQuiz = {
|
|
256
|
+
model: QuizQuestionModel;
|
|
257
|
+
section?: string;
|
|
258
|
+
scoring?: "exact" | "partial";
|
|
259
|
+
};
|
|
260
|
+
type SurveyStep = {
|
|
261
|
+
id: string;
|
|
262
|
+
kind: "content" | "single" | "multi" | "scale" | "fields"; /** Answer key; required for single / multi / scale. */
|
|
263
|
+
name?: string; /** Step is skipped on the path while this evaluates to false. */
|
|
264
|
+
showIf?: Predicate; /** Default true. */
|
|
265
|
+
back?: boolean; /** Advancing from this step submits the survey instead of moving on. */
|
|
266
|
+
terminal?: boolean; /** Step default navigation; a chosen option's `nav` overrides it. */
|
|
267
|
+
nav?: NavAction;
|
|
268
|
+
options?: SurveyOption[];
|
|
269
|
+
scale?: {
|
|
270
|
+
min: number;
|
|
271
|
+
max: number;
|
|
272
|
+
step?: number;
|
|
273
|
+
};
|
|
274
|
+
fields?: SurveyField[];
|
|
275
|
+
validate?: SurveyStepRule[];
|
|
276
|
+
quiz?: SurveyStepQuiz;
|
|
277
|
+
};
|
|
278
|
+
type SurveyDefinition = {
|
|
279
|
+
version: 1; /** Session storage key: `agera:survey:<id>`. */
|
|
280
|
+
id: string; /** Document order = linear order. */
|
|
281
|
+
steps: SurveyStep[]; /** Default "static" (matches the #145 fixture). */
|
|
282
|
+
progress?: "static" | "path";
|
|
283
|
+
quiz?: QuizConfig;
|
|
284
|
+
lang?: string;
|
|
285
|
+
};
|
|
286
|
+
type SurveyStatus = "idle" | "submitting" | "done" | "failed";
|
|
287
|
+
type SurveyState = {
|
|
288
|
+
version: 1;
|
|
289
|
+
definitionId: string;
|
|
290
|
+
current: string;
|
|
291
|
+
path: string[]; /** single → string, multi → string[], scale → number, fields → flattened by field name. */
|
|
292
|
+
answers: Record<string, unknown>;
|
|
293
|
+
status: SurveyStatus;
|
|
294
|
+
sessionId: string;
|
|
295
|
+
};
|
|
296
|
+
type SurveyErrors = Record<string, string>;
|
|
297
|
+
type SurveyView = {
|
|
298
|
+
step: SurveyStep;
|
|
299
|
+
index: number;
|
|
300
|
+
canBack: boolean;
|
|
301
|
+
isTerminal: boolean;
|
|
302
|
+
progress: ProgressModeInfo;
|
|
303
|
+
errors: SurveyErrors;
|
|
304
|
+
quiz: QuizResults | null;
|
|
305
|
+
};
|
|
306
|
+
/** What gets submitted: answers limited to steps on `path`, multi values joined with ", ". */
|
|
307
|
+
type SurveySnapshot = {
|
|
308
|
+
definitionId: string;
|
|
309
|
+
sessionId: string;
|
|
310
|
+
current: string;
|
|
311
|
+
path: string[];
|
|
312
|
+
answers: Record<string, string>;
|
|
313
|
+
};
|
|
314
|
+
type NavResult = {
|
|
315
|
+
kind: "moved";
|
|
316
|
+
to: string;
|
|
317
|
+
} | {
|
|
318
|
+
kind: "submit";
|
|
319
|
+
redirect?: string;
|
|
320
|
+
} | {
|
|
321
|
+
kind: "blocked";
|
|
322
|
+
errors: SurveyErrors;
|
|
323
|
+
} | {
|
|
324
|
+
kind: "stale";
|
|
325
|
+
} | {
|
|
326
|
+
kind: "noop";
|
|
327
|
+
};
|
|
328
|
+
type SurveyValidator = (input: {
|
|
329
|
+
step: SurveyStep;
|
|
330
|
+
answers: Record<string, unknown>;
|
|
331
|
+
lang?: string;
|
|
332
|
+
}) => SurveyErrors | null | Promise<SurveyErrors | null>;
|
|
333
|
+
type SurveyEngineOptions = {
|
|
334
|
+
initialState?: SurveyState; /** Used when `initialState` is absent. */
|
|
335
|
+
sessionId?: string; /** Extra validation on top of the built-in step rules; async results are discarded when stale. */
|
|
336
|
+
validate?: SurveyValidator;
|
|
337
|
+
};
|
|
338
|
+
type SurveyEngine = {
|
|
339
|
+
getState(): SurveyState;
|
|
340
|
+
getView(): SurveyView;
|
|
341
|
+
subscribe(listener: () => void): () => void; /** Set an answer without navigating. */
|
|
342
|
+
answer(name: string, value: unknown): void; /** Atomic answer + navigation against one snapshot. */
|
|
343
|
+
choose(name: string, value: unknown, nav?: NavAction): Promise<NavResult>; /** Validate the current step, then follow `nav` (or the step / option / linear default). */
|
|
344
|
+
next(nav?: NavAction): Promise<NavResult>;
|
|
345
|
+
back(): NavResult;
|
|
346
|
+
goto(id: string): NavResult;
|
|
347
|
+
snapshot(): SurveySnapshot; /** Submission lifecycle is driven by the consumer; navigation is blocked while "submitting". */
|
|
348
|
+
setStatus(status: SurveyStatus): void;
|
|
349
|
+
};
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/basin-submission.d.ts
|
|
352
|
+
type BasinFormDataInput = {
|
|
353
|
+
snapshot: SurveySnapshot;
|
|
354
|
+
step: number;
|
|
355
|
+
final: boolean;
|
|
356
|
+
jwt: string;
|
|
357
|
+
utms: Record<string, string>;
|
|
358
|
+
anonymousEmail: string;
|
|
359
|
+
stripFields?: Iterable<string>;
|
|
360
|
+
};
|
|
361
|
+
declare function hashSurveySnapshot(snapshot: SurveySnapshot): string;
|
|
362
|
+
declare function buildBasinFormData(input: BasinFormDataInput): FormData;
|
|
363
|
+
//#endregion
|
|
221
364
|
//#region src/lifecycle.d.ts
|
|
222
365
|
type NormalizedResult = {
|
|
223
366
|
delivery: "observed" | "unobserved";
|
|
@@ -618,5 +761,52 @@ declare function assembleCrmRequest({
|
|
|
618
761
|
//#region src/polling.d.ts
|
|
619
762
|
declare function matchesFormat(example: unknown, value: unknown): boolean;
|
|
620
763
|
//#endregion
|
|
621
|
-
|
|
764
|
+
//#region src/survey/engine.d.ts
|
|
765
|
+
declare function createSurveyEngine(definition: SurveyDefinition, options?: SurveyEngineOptions): SurveyEngine;
|
|
766
|
+
//#endregion
|
|
767
|
+
//#region src/survey/progress.d.ts
|
|
768
|
+
declare function computeProgress(definition: SurveyDefinition, state: SurveyState): ProgressModeInfo;
|
|
769
|
+
//#endregion
|
|
770
|
+
//#region src/survey/snapshot.d.ts
|
|
771
|
+
declare function buildSurveySnapshot(definition: SurveyDefinition, state: SurveyState): SurveySnapshot;
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/survey/quiz.d.ts
|
|
774
|
+
declare function deriveQuizInputs(definition: SurveyDefinition, state: SurveyState): QuizQuestionInput[];
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region src/survey/schema.d.ts
|
|
777
|
+
declare const surveyDefinitionSchema: z.ZodObject<{
|
|
778
|
+
version: z.ZodLiteral<1>;
|
|
779
|
+
id: z.ZodString;
|
|
780
|
+
steps: z.ZodArray<z.ZodType<SurveyStep, unknown, z.core.$ZodTypeInternals<SurveyStep, unknown>>>;
|
|
781
|
+
progress: z.ZodOptional<z.ZodEnum<{
|
|
782
|
+
path: "path";
|
|
783
|
+
static: "static";
|
|
784
|
+
}>>;
|
|
785
|
+
quiz: z.ZodOptional<z.ZodObject<{
|
|
786
|
+
passFail: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
787
|
+
percentGte: z.ZodNumber;
|
|
788
|
+
}, z.core.$strip>>>;
|
|
789
|
+
bands: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
790
|
+
id: z.ZodString;
|
|
791
|
+
label: z.ZodString;
|
|
792
|
+
minPercent: z.ZodOptional<z.ZodNumber>;
|
|
793
|
+
maxPercent: z.ZodOptional<z.ZodNumber>;
|
|
794
|
+
}, z.core.$strip>>>>;
|
|
795
|
+
sections: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
796
|
+
passFail: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
797
|
+
percentGte: z.ZodNumber;
|
|
798
|
+
}, z.core.$strip>>>;
|
|
799
|
+
bands: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
800
|
+
id: z.ZodString;
|
|
801
|
+
label: z.ZodString;
|
|
802
|
+
minPercent: z.ZodOptional<z.ZodNumber>;
|
|
803
|
+
maxPercent: z.ZodOptional<z.ZodNumber>;
|
|
804
|
+
}, z.core.$strip>>>>;
|
|
805
|
+
}, z.core.$strip>>>>;
|
|
806
|
+
}, z.core.$strip>>;
|
|
807
|
+
lang: z.ZodOptional<z.ZodString>;
|
|
808
|
+
}, z.core.$strip>;
|
|
809
|
+
declare function parseSurveyDefinition(input: unknown): SurveyDefinition;
|
|
810
|
+
//#endregion
|
|
811
|
+
export { type AssembleCrmRequestParams, type AttemptStorage, type BandQuizResults, CRM_CONTACT_KEYS, type CompositePredicate, type CounterDisplayConfig, type CounterPayload, type CounterResponse, type CounterUpdateConfig, type CrmCommand, type CrmConfig, type CrmContactKey, type CrmDispatch, type CrmFetchOptions, type CrmFetchResult, type CrmFrontendKey, type CrmGateway, type CrmGatewayResult, type CrmGroupedAnswer, type CrmJsonGroups, type CrmOutcome, type CrmV2PatchRequest, type CrmV2Payload, type CrmV2Request, type DeviceInput, type DeviceType, type ErrorMessages, type EventSource, type EventSourceKind, type FetchBasinJWTOptions, type FlowAttempt, type InsertDirective, type JsonValue, type JwtPayload, type LifecycleEvent, type LifecycleType, type MemoryCache, type MemoryCacheOptions, type MemoryCacheSetOptions, NavAction, NavResult, type NormalizedResult, type PassQuizResults, type PlacesMessageKey, type PlacesMessages, type Predicate, type PredicateOp, type ProgressInfo, type ProgressModeInfo, type QuestionMetrics, type QuizBandDefinition, type QuizConfig, type QuizOutcomeModel, type QuizPassFailConfig, type QuizQuestionInput, type QuizQuestionModel, type QuizQuestionResult, type QuizQuestionState, type QuizResults, type QuizScoringModel, type QuizSectionConfig, type QuizSectionResults, type RequestSnapshot, type ResolveValueSources, type ScoreQuizResults, type SessionState, type ShareConfig, type SimplePredicate, type SplitConfig, type StepState, type StorageAccessor, SurveyDefinition, SurveyEngine, SurveyEngineOptions, SurveyErrors, SurveyField, SurveyFieldRule, SurveyOption, type SurveyOptions, SurveySnapshot, SurveyState, SurveyStatus, SurveyStep, SurveyStepQuiz, SurveyStepRule, SurveyValidator, SurveyView, type TrackingConfig, type Utms, VISITOR_ID_STORAGE_KEY, type ValidationRule, type Validators, type ValueResolver, type WeightedQuizResults, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildBasinFormData, buildContext, buildCounterPayload, buildFcrmUtms, buildSurveySnapshot, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, computeProgress, createAttemptStore, createLifecycleBus, createMemoryCache, createSessionStore, createStepsStore, createSurveyEngine, createTrackingConsumer, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, deriveQuizInputs, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, firstDefined, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getOrCreateVisitorId, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, hashSurveySnapshot, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, mapTrackingEvent, matchesFormat, mergeAnswers, normalizeResult, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, parseSurveyDefinition, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, snapshot, surveyDefinitionSchema, unknownResult, updateUrlWithParams, validators };
|
|
622
812
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { atom } from "nanostores";
|
|
2
2
|
import log from "loglevel";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
//#region src/stores.ts
|
|
4
5
|
function createSessionStore(initial) {
|
|
5
6
|
return atom({
|
|
@@ -754,6 +755,54 @@ async function fetchBasinJWT(endpoint, storage, storageKey, options = {}) {
|
|
|
754
755
|
}
|
|
755
756
|
}
|
|
756
757
|
//#endregion
|
|
758
|
+
//#region src/basin-submission.ts
|
|
759
|
+
function normalizePiiKey(key) {
|
|
760
|
+
const lower = key.toLowerCase();
|
|
761
|
+
return lower.endsWith("-display") ? lower.slice(0, -8) : lower;
|
|
762
|
+
}
|
|
763
|
+
function stableSerialize(value) {
|
|
764
|
+
if (value === null) return "null";
|
|
765
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
766
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
767
|
+
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
|
|
768
|
+
if (typeof value === "object") {
|
|
769
|
+
const record = value;
|
|
770
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(",")}}`;
|
|
771
|
+
}
|
|
772
|
+
return JSON.stringify(String(value));
|
|
773
|
+
}
|
|
774
|
+
function hashSurveySnapshot(snapshot) {
|
|
775
|
+
const stable = stableSerialize({
|
|
776
|
+
current: snapshot.current,
|
|
777
|
+
path: snapshot.path,
|
|
778
|
+
answers: snapshot.answers
|
|
779
|
+
});
|
|
780
|
+
let hash = 2166136261;
|
|
781
|
+
for (let index = 0; index < stable.length; index += 1) {
|
|
782
|
+
hash ^= stable.charCodeAt(index);
|
|
783
|
+
hash = Math.imul(hash, 16777619);
|
|
784
|
+
}
|
|
785
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
786
|
+
}
|
|
787
|
+
function buildBasinFormData(input) {
|
|
788
|
+
const formData = new FormData();
|
|
789
|
+
const stripFields = input.stripFields ? new Set(Array.from(input.stripFields, normalizePiiKey)) : null;
|
|
790
|
+
for (const [name, value] of Object.entries(input.snapshot.answers)) {
|
|
791
|
+
if (stripFields?.has(normalizePiiKey(name))) continue;
|
|
792
|
+
formData.append(name, value);
|
|
793
|
+
}
|
|
794
|
+
formData.append("frombasinjs", "true");
|
|
795
|
+
formData.append("_gotcha", "");
|
|
796
|
+
const email = formData.get("email");
|
|
797
|
+
if (typeof email !== "string" || email.trim() === "") formData.set("email", input.anonymousEmail);
|
|
798
|
+
for (const [name, value] of Object.entries(input.utms)) formData.append(name, value);
|
|
799
|
+
if (input.jwt.trim() !== "") formData.append("basinjwt", input.jwt);
|
|
800
|
+
formData.append("basinjsmultistep", "true");
|
|
801
|
+
formData.append("basinjsdraft", input.final ? "false" : "true");
|
|
802
|
+
if (!input.final) formData.append("basinjsstep", String(input.step));
|
|
803
|
+
return formData;
|
|
804
|
+
}
|
|
805
|
+
//#endregion
|
|
757
806
|
//#region src/lifecycle.ts
|
|
758
807
|
function unknownResult(delivery = "unobserved") {
|
|
759
808
|
return {
|
|
@@ -1566,6 +1615,719 @@ function matchesFormat(example, value) {
|
|
|
1566
1615
|
return example === value;
|
|
1567
1616
|
}
|
|
1568
1617
|
//#endregion
|
|
1569
|
-
|
|
1618
|
+
//#region src/survey/progress.ts
|
|
1619
|
+
function stepPassesShowIf(step, answers) {
|
|
1620
|
+
return step.showIf === void 0 || evaluatePredicate(step.showIf, answers);
|
|
1621
|
+
}
|
|
1622
|
+
function percent(current, total) {
|
|
1623
|
+
if (total === 0) return 0;
|
|
1624
|
+
return Math.round(current / total * 100);
|
|
1625
|
+
}
|
|
1626
|
+
function computeProgress(definition, state) {
|
|
1627
|
+
if ((definition.progress ?? "static") === "path") {
|
|
1628
|
+
const currentIndex = definition.steps.findIndex((step) => step.id === state.current);
|
|
1629
|
+
const stepsAfterCurrent = currentIndex < 0 ? [] : definition.steps.slice(currentIndex + 1).filter((step) => stepPassesShowIf(step, state.answers));
|
|
1630
|
+
const current = state.path.length;
|
|
1631
|
+
const total = current + stepsAfterCurrent.length;
|
|
1632
|
+
return {
|
|
1633
|
+
current,
|
|
1634
|
+
total,
|
|
1635
|
+
percent: percent(current, total)
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
const documentIndex = definition.steps.findIndex((step) => step.id === state.current);
|
|
1639
|
+
const current = documentIndex >= 0 ? documentIndex + 1 : 1;
|
|
1640
|
+
const total = definition.steps.length;
|
|
1641
|
+
return {
|
|
1642
|
+
current,
|
|
1643
|
+
total,
|
|
1644
|
+
percent: percent(current, total)
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
//#endregion
|
|
1648
|
+
//#region src/survey/quiz.ts
|
|
1649
|
+
function answeredValuesForStep(step, state) {
|
|
1650
|
+
if (!step.name) return [];
|
|
1651
|
+
const answer = state.answers[step.name];
|
|
1652
|
+
if (step.quiz?.model === "single_select") return typeof answer === "string" ? [answer] : [];
|
|
1653
|
+
return Array.isArray(answer) ? answer.filter((value) => typeof value === "string") : [];
|
|
1654
|
+
}
|
|
1655
|
+
function weightedScoresForValues(values, step) {
|
|
1656
|
+
const scores = {};
|
|
1657
|
+
for (const value of values) {
|
|
1658
|
+
const option = step.options?.find((candidate) => candidate.value === value);
|
|
1659
|
+
if (!option?.weights) continue;
|
|
1660
|
+
for (const [category, weight] of Object.entries(option.weights)) scores[category] = (scores[category] ?? 0) + weight;
|
|
1661
|
+
}
|
|
1662
|
+
return scores;
|
|
1663
|
+
}
|
|
1664
|
+
function labelsForValues(values, step) {
|
|
1665
|
+
return values.map((value) => step.options?.find((option) => option.value === value)?.label ?? value);
|
|
1666
|
+
}
|
|
1667
|
+
function deriveQuizInputs(definition, state) {
|
|
1668
|
+
const pathIds = new Set(state.path);
|
|
1669
|
+
return definition.steps.flatMap((step) => {
|
|
1670
|
+
if (!step.quiz) return [];
|
|
1671
|
+
const answeredValues = answeredValuesForStep(step, state);
|
|
1672
|
+
const correctOptions = (step.options ?? []).filter((option) => option.correct);
|
|
1673
|
+
const expectedValues = correctOptions.length > 0 ? correctOptions.map((option) => option.value) : null;
|
|
1674
|
+
const expectedLabels = correctOptions.length > 0 ? correctOptions.map((option) => option.label ?? option.value) : null;
|
|
1675
|
+
return [{
|
|
1676
|
+
key: step.name ?? step.id,
|
|
1677
|
+
model: step.quiz.model,
|
|
1678
|
+
section: step.quiz.section ?? null,
|
|
1679
|
+
eligible: pathIds.has(step.id),
|
|
1680
|
+
answeredValues,
|
|
1681
|
+
answeredLabels: labelsForValues(answeredValues, step),
|
|
1682
|
+
expectedValues,
|
|
1683
|
+
expectedLabels,
|
|
1684
|
+
scoringModel: step.quiz.scoring ?? (expectedValues ? "exact" : null),
|
|
1685
|
+
usesWeightedScoring: (step.options ?? []).some((option) => option.weights !== void 0),
|
|
1686
|
+
weightedScores: weightedScoresForValues(answeredValues, step)
|
|
1687
|
+
}];
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
//#endregion
|
|
1691
|
+
//#region src/survey/snapshot.ts
|
|
1692
|
+
function stringifyAnswer(value) {
|
|
1693
|
+
if (typeof value === "string") return value;
|
|
1694
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value.join(", ");
|
|
1695
|
+
if (typeof value === "number") return String(value);
|
|
1696
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
1697
|
+
}
|
|
1698
|
+
function addAnswer(output, answers, name) {
|
|
1699
|
+
const value = stringifyAnswer(answers[name]);
|
|
1700
|
+
if (value !== void 0) output[name] = value;
|
|
1701
|
+
}
|
|
1702
|
+
function addStepAnswers(output, answers, step) {
|
|
1703
|
+
if (step.kind === "fields") {
|
|
1704
|
+
for (const field of step.fields ?? []) addAnswer(output, answers, field.name);
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if (step.name) {
|
|
1708
|
+
addAnswer(output, answers, step.name);
|
|
1709
|
+
if (step.kind === "single" || step.kind === "multi") {
|
|
1710
|
+
const selected = answers[step.name];
|
|
1711
|
+
const selectedValues = typeof selected === "string" ? [selected] : Array.isArray(selected) && selected.every((value) => typeof value === "string") ? selected : [];
|
|
1712
|
+
const otherOption = step.options?.find((option) => option.other === true && selectedValues.includes(option.value));
|
|
1713
|
+
const otherAnswer = answers[`${step.name}_other`];
|
|
1714
|
+
if (otherOption && typeof otherAnswer === "string" && otherAnswer.trim() !== "") addAnswer(output, answers, `${step.name}_other`);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
function buildSurveySnapshot(definition, state) {
|
|
1719
|
+
const pathIds = new Set(state.path);
|
|
1720
|
+
const answers = {};
|
|
1721
|
+
for (const step of definition.steps) if (pathIds.has(step.id)) addStepAnswers(answers, state.answers, step);
|
|
1722
|
+
return {
|
|
1723
|
+
definitionId: state.definitionId,
|
|
1724
|
+
sessionId: state.sessionId,
|
|
1725
|
+
current: state.current,
|
|
1726
|
+
path: [...state.path],
|
|
1727
|
+
answers
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
//#endregion
|
|
1731
|
+
//#region src/survey/validate.ts
|
|
1732
|
+
function isEmpty(value) {
|
|
1733
|
+
if (value === void 0 || value === null) return true;
|
|
1734
|
+
if (typeof value === "string") return value.trim() === "";
|
|
1735
|
+
return Array.isArray(value) && value.length === 0;
|
|
1736
|
+
}
|
|
1737
|
+
function asString(value) {
|
|
1738
|
+
if (typeof value === "string") return value;
|
|
1739
|
+
if (value === void 0 || value === null) return "";
|
|
1740
|
+
return String(value);
|
|
1741
|
+
}
|
|
1742
|
+
function replaceMessage(template, replacements) {
|
|
1743
|
+
return Object.entries(replacements).reduce((message, [key, replacement]) => message.replaceAll(`{${key}}`, replacement), template);
|
|
1744
|
+
}
|
|
1745
|
+
function getMessage(lang, key, replacements = {}) {
|
|
1746
|
+
const messages = getErrorMessagesForLanguage(lang);
|
|
1747
|
+
const fallbackKey = key === "postal" ? "pattern" : key;
|
|
1748
|
+
return replaceMessage(messages[key] ?? messages[fallbackKey] ?? messages.required ?? "Invalid value", {
|
|
1749
|
+
...key === "phone" ? { example: getPhoneExample() } : {},
|
|
1750
|
+
...replacements
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
function addError(errors, key, message) {
|
|
1754
|
+
if (errors[key] === void 0) errors[key] = message;
|
|
1755
|
+
}
|
|
1756
|
+
function validateRequired(value, key, lang, errors) {
|
|
1757
|
+
if (isEmpty(value)) addError(errors, key, getMessage(lang, "required"));
|
|
1758
|
+
}
|
|
1759
|
+
function validateStepRule(rule, value, key, lang, errors) {
|
|
1760
|
+
if (rule.rule === "required") {
|
|
1761
|
+
validateRequired(value, key, lang, errors);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
const selectedCount = Array.isArray(value) ? value.length : 0;
|
|
1765
|
+
if (rule.rule === "minSelected" && selectedCount < rule.arg) {
|
|
1766
|
+
addError(errors, key, getMessage(lang, "minValue", { minValue: String(rule.arg) }));
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
if (rule.rule === "maxSelected" && selectedCount > rule.arg) addError(errors, key, getMessage(lang, "maxValue", { maxValue: String(rule.arg) }));
|
|
1770
|
+
}
|
|
1771
|
+
function validateScale(step, value, key, lang, errors) {
|
|
1772
|
+
if (!step.scale) return;
|
|
1773
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1774
|
+
addError(errors, key, getMessage(lang, "minValue", { minValue: String(step.scale.min) }));
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (value < step.scale.min) {
|
|
1778
|
+
addError(errors, key, getMessage(lang, "minValue", { minValue: String(step.scale.min) }));
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
if (value > step.scale.max) addError(errors, key, getMessage(lang, "maxValue", { maxValue: String(step.scale.max) }));
|
|
1782
|
+
}
|
|
1783
|
+
function phoneIsValid(value) {
|
|
1784
|
+
const stripped = value.replace(/[\s()+-]/g, "");
|
|
1785
|
+
return /^\d{6,15}$/.test(stripped);
|
|
1786
|
+
}
|
|
1787
|
+
function postalIsValid(value) {
|
|
1788
|
+
return /^\d{4}$/.test(value);
|
|
1789
|
+
}
|
|
1790
|
+
function fieldRuleIsValid(rule, value) {
|
|
1791
|
+
if (typeof rule === "string") {
|
|
1792
|
+
const stringValue = asString(value);
|
|
1793
|
+
switch (rule) {
|
|
1794
|
+
case "required": return !isEmpty(value);
|
|
1795
|
+
case "email": return validators.email(stringValue);
|
|
1796
|
+
case "name": return validators.name(stringValue);
|
|
1797
|
+
case "phone": return phoneIsValid(stringValue);
|
|
1798
|
+
case "postal": return postalIsValid(stringValue);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
const stringValue = asString(value);
|
|
1802
|
+
return validators[rule.rule](stringValue, rule.arg);
|
|
1803
|
+
}
|
|
1804
|
+
function fieldRuleMessage(rule, lang) {
|
|
1805
|
+
if (typeof rule === "string") return getMessage(lang, rule);
|
|
1806
|
+
if (rule.rule === "pattern") return getMessage(lang, "pattern");
|
|
1807
|
+
return getMessage(lang, rule.rule, { [rule.rule]: rule.arg });
|
|
1808
|
+
}
|
|
1809
|
+
function validateSurveyStep(step, answers, lang) {
|
|
1810
|
+
const errors = {};
|
|
1811
|
+
if (step.kind === "fields") {
|
|
1812
|
+
for (const field of step.fields ?? []) {
|
|
1813
|
+
const value = answers[field.name];
|
|
1814
|
+
for (const rule of field.rules ?? []) {
|
|
1815
|
+
if (fieldRuleIsValid(rule, value)) continue;
|
|
1816
|
+
addError(errors, field.name, fieldRuleMessage(rule, lang));
|
|
1817
|
+
break;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return Object.keys(errors).length > 0 ? errors : null;
|
|
1821
|
+
}
|
|
1822
|
+
const key = step.name;
|
|
1823
|
+
if (!key) return null;
|
|
1824
|
+
const value = answers[key];
|
|
1825
|
+
for (const rule of step.validate ?? []) validateStepRule(rule, value, key, lang, errors);
|
|
1826
|
+
if (step.kind === "scale") validateScale(step, value, key, lang, errors);
|
|
1827
|
+
return Object.keys(errors).length > 0 ? errors : null;
|
|
1828
|
+
}
|
|
1829
|
+
//#endregion
|
|
1830
|
+
//#region src/survey/engine.ts
|
|
1831
|
+
function cloneValue(value, seen = /* @__PURE__ */ new WeakMap()) {
|
|
1832
|
+
if (value === null || typeof value !== "object") return value;
|
|
1833
|
+
const existing = seen.get(value);
|
|
1834
|
+
if (existing !== void 0) return existing;
|
|
1835
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
1836
|
+
if (Array.isArray(value)) {
|
|
1837
|
+
const copy = [];
|
|
1838
|
+
seen.set(value, copy);
|
|
1839
|
+
for (const item of value) copy.push(cloneValue(item, seen));
|
|
1840
|
+
return copy;
|
|
1841
|
+
}
|
|
1842
|
+
const copy = {};
|
|
1843
|
+
seen.set(value, copy);
|
|
1844
|
+
for (const [key, item] of Object.entries(value)) copy[key] = cloneValue(item, seen);
|
|
1845
|
+
return copy;
|
|
1846
|
+
}
|
|
1847
|
+
function cloneState(state) {
|
|
1848
|
+
return {
|
|
1849
|
+
version: state.version,
|
|
1850
|
+
definitionId: state.definitionId,
|
|
1851
|
+
current: state.current,
|
|
1852
|
+
path: [...state.path],
|
|
1853
|
+
answers: cloneValue(state.answers),
|
|
1854
|
+
status: state.status,
|
|
1855
|
+
sessionId: state.sessionId
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
function randomSessionId() {
|
|
1859
|
+
const cryptoApi = globalThis.crypto;
|
|
1860
|
+
if (cryptoApi && typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID();
|
|
1861
|
+
if (cryptoApi && typeof cryptoApi.getRandomValues === "function") {
|
|
1862
|
+
const bytes = cryptoApi.getRandomValues(new Uint8Array(8));
|
|
1863
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1864
|
+
}
|
|
1865
|
+
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
|
|
1866
|
+
}
|
|
1867
|
+
function findStep(definition, id) {
|
|
1868
|
+
return definition.steps.find((step) => step.id === id);
|
|
1869
|
+
}
|
|
1870
|
+
function getInitialStep(definition, answers) {
|
|
1871
|
+
return definition.steps.find((step) => stepPassesShowIf(step, answers)) ?? definition.steps[0];
|
|
1872
|
+
}
|
|
1873
|
+
function restoreOrCreateState(definition, options, sessionId) {
|
|
1874
|
+
const initialState = options.initialState;
|
|
1875
|
+
if (initialState?.definitionId === definition.id && findStep(definition, initialState.current)) return {
|
|
1876
|
+
...cloneState(initialState),
|
|
1877
|
+
sessionId
|
|
1878
|
+
};
|
|
1879
|
+
const current = getInitialStep(definition, {});
|
|
1880
|
+
return {
|
|
1881
|
+
version: 1,
|
|
1882
|
+
definitionId: definition.id,
|
|
1883
|
+
current: current.id,
|
|
1884
|
+
path: [current.id],
|
|
1885
|
+
answers: {},
|
|
1886
|
+
status: "idle",
|
|
1887
|
+
sessionId
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
function findNextVisibleStep(definition, index, answers) {
|
|
1891
|
+
return definition.steps.slice(index + 1).find((step) => stepPassesShowIf(step, answers));
|
|
1892
|
+
}
|
|
1893
|
+
function resolveDestination(definition, current, action, answers) {
|
|
1894
|
+
if (action.kind === "goto") {
|
|
1895
|
+
const target = findStep(definition, action.target);
|
|
1896
|
+
if (!target) return { kind: "noop" };
|
|
1897
|
+
if (current.terminal) return { kind: "submit" };
|
|
1898
|
+
if (stepPassesShowIf(target, answers)) return {
|
|
1899
|
+
kind: "step",
|
|
1900
|
+
id: target.id,
|
|
1901
|
+
truncate: true
|
|
1902
|
+
};
|
|
1903
|
+
const next = findNextVisibleStep(definition, definition.steps.findIndex((step) => step.id === target.id), answers);
|
|
1904
|
+
return next ? {
|
|
1905
|
+
kind: "step",
|
|
1906
|
+
id: next.id,
|
|
1907
|
+
truncate: false
|
|
1908
|
+
} : { kind: "submit" };
|
|
1909
|
+
}
|
|
1910
|
+
if (action.kind === "redirect") return {
|
|
1911
|
+
kind: "submit",
|
|
1912
|
+
redirect: action.target
|
|
1913
|
+
};
|
|
1914
|
+
if (current.terminal) return { kind: "submit" };
|
|
1915
|
+
const next = findNextVisibleStep(definition, definition.steps.findIndex((step) => step.id === current.id), answers);
|
|
1916
|
+
return next ? {
|
|
1917
|
+
kind: "step",
|
|
1918
|
+
id: next.id,
|
|
1919
|
+
truncate: false
|
|
1920
|
+
} : { kind: "submit" };
|
|
1921
|
+
}
|
|
1922
|
+
function submissionResult(redirect) {
|
|
1923
|
+
return redirect === void 0 ? { kind: "submit" } : {
|
|
1924
|
+
kind: "submit",
|
|
1925
|
+
redirect
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
function createSurveyEngine(definition, options = {}) {
|
|
1929
|
+
if (definition.steps.length === 0) throw new Error("A survey definition must contain at least one step");
|
|
1930
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1931
|
+
for (const step of definition.steps) {
|
|
1932
|
+
if (ids.has(step.id)) throw new Error(`Duplicate survey step id: ${step.id}`);
|
|
1933
|
+
ids.add(step.id);
|
|
1934
|
+
}
|
|
1935
|
+
let state = restoreOrCreateState(definition, options, options.sessionId ?? options.initialState?.sessionId ?? randomSessionId());
|
|
1936
|
+
let errors = {};
|
|
1937
|
+
let revision = 0;
|
|
1938
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1939
|
+
function notify() {
|
|
1940
|
+
for (const listener of listeners) try {
|
|
1941
|
+
listener();
|
|
1942
|
+
} catch {}
|
|
1943
|
+
}
|
|
1944
|
+
function replaceState(next) {
|
|
1945
|
+
state = cloneState(next);
|
|
1946
|
+
revision += 1;
|
|
1947
|
+
notify();
|
|
1948
|
+
}
|
|
1949
|
+
function replaceErrors(next) {
|
|
1950
|
+
errors = { ...next };
|
|
1951
|
+
notify();
|
|
1952
|
+
}
|
|
1953
|
+
function clearErrors() {
|
|
1954
|
+
if (Object.keys(errors).length === 0) return;
|
|
1955
|
+
errors = {};
|
|
1956
|
+
}
|
|
1957
|
+
function currentStep() {
|
|
1958
|
+
return findStep(definition, state.current) ?? definition.steps[0];
|
|
1959
|
+
}
|
|
1960
|
+
function isNavigationBlocked() {
|
|
1961
|
+
return state.status === "submitting" || state.status === "done";
|
|
1962
|
+
}
|
|
1963
|
+
function applyAnswer(name, value) {
|
|
1964
|
+
clearErrors();
|
|
1965
|
+
replaceState({
|
|
1966
|
+
...state,
|
|
1967
|
+
answers: {
|
|
1968
|
+
...state.answers,
|
|
1969
|
+
[name]: cloneValue(value)
|
|
1970
|
+
}
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
function moveTo(id, truncate) {
|
|
1974
|
+
const pathIndex = truncate ? state.path.lastIndexOf(id) : -1;
|
|
1975
|
+
const path = pathIndex >= 0 ? state.path.slice(0, pathIndex + 1) : [...state.path, id];
|
|
1976
|
+
if (state.current === id && path.length === state.path.length) return {
|
|
1977
|
+
kind: "moved",
|
|
1978
|
+
to: id
|
|
1979
|
+
};
|
|
1980
|
+
clearErrors();
|
|
1981
|
+
replaceState({
|
|
1982
|
+
...state,
|
|
1983
|
+
current: id,
|
|
1984
|
+
path
|
|
1985
|
+
});
|
|
1986
|
+
return {
|
|
1987
|
+
kind: "moved",
|
|
1988
|
+
to: id
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
function applyDestination(destination) {
|
|
1992
|
+
if (destination.kind === "noop") return { kind: "noop" };
|
|
1993
|
+
if (destination.kind === "submit") {
|
|
1994
|
+
replaceState({
|
|
1995
|
+
...state,
|
|
1996
|
+
status: "submitting"
|
|
1997
|
+
});
|
|
1998
|
+
return submissionResult(destination.redirect);
|
|
1999
|
+
}
|
|
2000
|
+
return moveTo(destination.id, destination.truncate);
|
|
2001
|
+
}
|
|
2002
|
+
function resolveAction(step, value, explicitNav) {
|
|
2003
|
+
if (explicitNav) return explicitNav;
|
|
2004
|
+
if (step.kind === "single" && typeof value === "string") {
|
|
2005
|
+
const optionNav = step.options?.find((option) => option.value === value)?.nav;
|
|
2006
|
+
if (optionNav) return optionNav;
|
|
2007
|
+
}
|
|
2008
|
+
return step.nav ?? { kind: "next" };
|
|
2009
|
+
}
|
|
2010
|
+
async function validateBeforeMove(step, answers) {
|
|
2011
|
+
const builtInErrors = validateSurveyStep(step, answers, definition.lang);
|
|
2012
|
+
if (builtInErrors) return builtInErrors;
|
|
2013
|
+
if (!options.validate) return null;
|
|
2014
|
+
return options.validate({
|
|
2015
|
+
step: cloneValue(step),
|
|
2016
|
+
answers: cloneValue(answers),
|
|
2017
|
+
lang: definition.lang
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
async function next(nav) {
|
|
2021
|
+
if (isNavigationBlocked()) return { kind: "noop" };
|
|
2022
|
+
const step = currentStep();
|
|
2023
|
+
const answers = cloneValue(state.answers);
|
|
2024
|
+
const capturedRevision = revision;
|
|
2025
|
+
const validationErrors = await validateBeforeMove(step, answers);
|
|
2026
|
+
if (revision !== capturedRevision) return { kind: "stale" };
|
|
2027
|
+
if (validationErrors && Object.keys(validationErrors).length > 0) {
|
|
2028
|
+
replaceErrors(validationErrors);
|
|
2029
|
+
return {
|
|
2030
|
+
kind: "blocked",
|
|
2031
|
+
errors: { ...validationErrors }
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
return applyDestination(resolveDestination(definition, step, resolveAction(step, step.name ? answers[step.name] : void 0, nav), answers));
|
|
2035
|
+
}
|
|
2036
|
+
async function choose(name, value, nav) {
|
|
2037
|
+
if (isNavigationBlocked()) return { kind: "noop" };
|
|
2038
|
+
const step = currentStep();
|
|
2039
|
+
applyAnswer(name, value);
|
|
2040
|
+
const answers = cloneValue(state.answers);
|
|
2041
|
+
const capturedRevision = revision;
|
|
2042
|
+
const validationErrors = await validateBeforeMove(step, answers);
|
|
2043
|
+
if (revision !== capturedRevision) return { kind: "stale" };
|
|
2044
|
+
if (validationErrors && Object.keys(validationErrors).length > 0) {
|
|
2045
|
+
replaceErrors(validationErrors);
|
|
2046
|
+
return {
|
|
2047
|
+
kind: "blocked",
|
|
2048
|
+
errors: { ...validationErrors }
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
return applyDestination(resolveDestination(definition, step, resolveAction(step, value, nav), answers));
|
|
2052
|
+
}
|
|
2053
|
+
function back() {
|
|
2054
|
+
if (isNavigationBlocked()) return { kind: "noop" };
|
|
2055
|
+
if (currentStep().back === false || state.path.length <= 1) return { kind: "noop" };
|
|
2056
|
+
const path = state.path.slice(0, -1);
|
|
2057
|
+
const previous = path[path.length - 1];
|
|
2058
|
+
if (!previous) return { kind: "noop" };
|
|
2059
|
+
clearErrors();
|
|
2060
|
+
replaceState({
|
|
2061
|
+
...state,
|
|
2062
|
+
current: previous,
|
|
2063
|
+
path
|
|
2064
|
+
});
|
|
2065
|
+
return {
|
|
2066
|
+
kind: "moved",
|
|
2067
|
+
to: previous
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
function goto(id) {
|
|
2071
|
+
if (isNavigationBlocked()) return { kind: "noop" };
|
|
2072
|
+
const step = currentStep();
|
|
2073
|
+
const answers = cloneValue(state.answers);
|
|
2074
|
+
return applyDestination(resolveDestination(definition, step, {
|
|
2075
|
+
kind: "goto",
|
|
2076
|
+
target: id
|
|
2077
|
+
}, answers));
|
|
2078
|
+
}
|
|
2079
|
+
function getState() {
|
|
2080
|
+
return cloneState(state);
|
|
2081
|
+
}
|
|
2082
|
+
function getView() {
|
|
2083
|
+
const step = currentStep();
|
|
2084
|
+
const inputs = deriveQuizInputs(definition, state);
|
|
2085
|
+
return {
|
|
2086
|
+
step: cloneValue(step),
|
|
2087
|
+
index: definition.steps.findIndex((candidate) => candidate.id === step.id),
|
|
2088
|
+
canBack: !isNavigationBlocked() && state.path.length > 1 && step.back !== false,
|
|
2089
|
+
isTerminal: step.terminal === true,
|
|
2090
|
+
progress: computeProgress(definition, state),
|
|
2091
|
+
errors: { ...errors },
|
|
2092
|
+
quiz: inputs.length > 0 ? scoreQuiz(inputs, definition.quiz ?? {}) : null
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
function subscribe(listener) {
|
|
2096
|
+
listeners.add(listener);
|
|
2097
|
+
return () => {
|
|
2098
|
+
listeners.delete(listener);
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
function answer(name, value) {
|
|
2102
|
+
applyAnswer(name, value);
|
|
2103
|
+
}
|
|
2104
|
+
function snapshot() {
|
|
2105
|
+
return buildSurveySnapshot(definition, state);
|
|
2106
|
+
}
|
|
2107
|
+
function setStatus(status) {
|
|
2108
|
+
replaceState({
|
|
2109
|
+
...state,
|
|
2110
|
+
status
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
return {
|
|
2114
|
+
getState,
|
|
2115
|
+
getView,
|
|
2116
|
+
subscribe,
|
|
2117
|
+
answer,
|
|
2118
|
+
choose,
|
|
2119
|
+
next,
|
|
2120
|
+
back,
|
|
2121
|
+
goto,
|
|
2122
|
+
snapshot,
|
|
2123
|
+
setStatus
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
//#endregion
|
|
2127
|
+
//#region src/survey/schema.ts
|
|
2128
|
+
const predicateOpSchema = z.enum([
|
|
2129
|
+
"eq",
|
|
2130
|
+
"neq",
|
|
2131
|
+
"in",
|
|
2132
|
+
"nin",
|
|
2133
|
+
"gt",
|
|
2134
|
+
"gte",
|
|
2135
|
+
"lt",
|
|
2136
|
+
"lte",
|
|
2137
|
+
"exists",
|
|
2138
|
+
"truthy",
|
|
2139
|
+
"falsy",
|
|
2140
|
+
"contains",
|
|
2141
|
+
"starts_with",
|
|
2142
|
+
"ends_with"
|
|
2143
|
+
]);
|
|
2144
|
+
const predicateSchema = z.lazy(() => z.union([z.object({
|
|
2145
|
+
field: z.string(),
|
|
2146
|
+
op: predicateOpSchema,
|
|
2147
|
+
value: z.unknown().optional()
|
|
2148
|
+
}), z.object({
|
|
2149
|
+
all: z.array(predicateSchema).optional(),
|
|
2150
|
+
any: z.array(predicateSchema).optional(),
|
|
2151
|
+
not: predicateSchema.optional()
|
|
2152
|
+
}).refine((predicate) => predicate.all !== void 0 || predicate.any !== void 0 || predicate.not !== void 0)]));
|
|
2153
|
+
const navActionSchema = z.discriminatedUnion("kind", [
|
|
2154
|
+
z.object({ kind: z.literal("next") }),
|
|
2155
|
+
z.object({
|
|
2156
|
+
kind: z.literal("goto"),
|
|
2157
|
+
target: z.string()
|
|
2158
|
+
}),
|
|
2159
|
+
z.object({
|
|
2160
|
+
kind: z.literal("redirect"),
|
|
2161
|
+
target: z.string()
|
|
2162
|
+
})
|
|
2163
|
+
]);
|
|
2164
|
+
const surveyOptionSchema = z.object({
|
|
2165
|
+
value: z.string(),
|
|
2166
|
+
label: z.string().optional(),
|
|
2167
|
+
nav: navActionSchema.optional(),
|
|
2168
|
+
correct: z.boolean().optional(),
|
|
2169
|
+
weights: z.record(z.string(), z.number()).optional(),
|
|
2170
|
+
other: z.boolean().optional()
|
|
2171
|
+
});
|
|
2172
|
+
const surveyFieldRuleSchema = z.union([z.enum([
|
|
2173
|
+
"required",
|
|
2174
|
+
"email",
|
|
2175
|
+
"name",
|
|
2176
|
+
"phone",
|
|
2177
|
+
"postal"
|
|
2178
|
+
]), z.object({
|
|
2179
|
+
rule: z.enum([
|
|
2180
|
+
"pattern",
|
|
2181
|
+
"min",
|
|
2182
|
+
"max"
|
|
2183
|
+
]),
|
|
2184
|
+
arg: z.string()
|
|
2185
|
+
})]);
|
|
2186
|
+
const surveyFieldSchema = z.object({
|
|
2187
|
+
name: z.string(),
|
|
2188
|
+
type: z.enum([
|
|
2189
|
+
"text",
|
|
2190
|
+
"email",
|
|
2191
|
+
"tel",
|
|
2192
|
+
"textarea",
|
|
2193
|
+
"postal",
|
|
2194
|
+
"address"
|
|
2195
|
+
]),
|
|
2196
|
+
rules: z.array(surveyFieldRuleSchema).optional()
|
|
2197
|
+
});
|
|
2198
|
+
const surveyStepRuleSchema = z.union([z.object({ rule: z.literal("required") }), z.object({
|
|
2199
|
+
rule: z.enum(["minSelected", "maxSelected"]),
|
|
2200
|
+
arg: z.number()
|
|
2201
|
+
})]);
|
|
2202
|
+
const surveyStepQuizSchema = z.object({
|
|
2203
|
+
model: z.enum(["single_select", "multi_select"]),
|
|
2204
|
+
section: z.string().optional(),
|
|
2205
|
+
scoring: z.enum(["exact", "partial"]).optional()
|
|
2206
|
+
});
|
|
2207
|
+
const quizPassFailSchema = z.object({ percentGte: z.number() });
|
|
2208
|
+
const quizBandSchema = z.object({
|
|
2209
|
+
id: z.string(),
|
|
2210
|
+
label: z.string(),
|
|
2211
|
+
minPercent: z.number().optional(),
|
|
2212
|
+
maxPercent: z.number().optional()
|
|
2213
|
+
});
|
|
2214
|
+
const quizSectionSchema = z.object({
|
|
2215
|
+
passFail: quizPassFailSchema.nullable().optional(),
|
|
2216
|
+
bands: z.array(quizBandSchema).nullable().optional()
|
|
2217
|
+
});
|
|
2218
|
+
const quizConfigSchema = z.object({
|
|
2219
|
+
passFail: quizPassFailSchema.nullable().optional(),
|
|
2220
|
+
bands: z.array(quizBandSchema).nullable().optional(),
|
|
2221
|
+
sections: z.record(z.string(), quizSectionSchema).nullable().optional()
|
|
2222
|
+
});
|
|
2223
|
+
const surveyStepSchema = z.object({
|
|
2224
|
+
id: z.string(),
|
|
2225
|
+
kind: z.enum([
|
|
2226
|
+
"content",
|
|
2227
|
+
"single",
|
|
2228
|
+
"multi",
|
|
2229
|
+
"scale",
|
|
2230
|
+
"fields"
|
|
2231
|
+
]),
|
|
2232
|
+
name: z.string().optional(),
|
|
2233
|
+
showIf: predicateSchema.optional(),
|
|
2234
|
+
back: z.boolean().optional(),
|
|
2235
|
+
terminal: z.boolean().optional(),
|
|
2236
|
+
nav: navActionSchema.optional(),
|
|
2237
|
+
options: z.array(surveyOptionSchema).optional(),
|
|
2238
|
+
scale: z.object({
|
|
2239
|
+
min: z.number(),
|
|
2240
|
+
max: z.number(),
|
|
2241
|
+
step: z.number().optional()
|
|
2242
|
+
}).optional(),
|
|
2243
|
+
fields: z.array(surveyFieldSchema).optional(),
|
|
2244
|
+
validate: z.array(surveyStepRuleSchema).optional(),
|
|
2245
|
+
quiz: surveyStepQuizSchema.optional()
|
|
2246
|
+
});
|
|
2247
|
+
const surveyDefinitionSchema = z.object({
|
|
2248
|
+
version: z.literal(1),
|
|
2249
|
+
id: z.string(),
|
|
2250
|
+
steps: z.array(surveyStepSchema),
|
|
2251
|
+
progress: z.enum(["static", "path"]).optional(),
|
|
2252
|
+
quiz: quizConfigSchema.optional(),
|
|
2253
|
+
lang: z.string().optional()
|
|
2254
|
+
}).superRefine((definition, context) => {
|
|
2255
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2256
|
+
definition.steps.forEach((step, index) => {
|
|
2257
|
+
if (ids.has(step.id)) context.addIssue({
|
|
2258
|
+
code: "custom",
|
|
2259
|
+
message: `Duplicate step id: ${step.id}`,
|
|
2260
|
+
path: [
|
|
2261
|
+
"steps",
|
|
2262
|
+
index,
|
|
2263
|
+
"id"
|
|
2264
|
+
]
|
|
2265
|
+
});
|
|
2266
|
+
ids.add(step.id);
|
|
2267
|
+
if ((step.kind === "single" || step.kind === "multi" || step.kind === "scale") && !step.name) context.addIssue({
|
|
2268
|
+
code: "custom",
|
|
2269
|
+
message: `Step "${step.id}" requires a name`,
|
|
2270
|
+
path: [
|
|
2271
|
+
"steps",
|
|
2272
|
+
index,
|
|
2273
|
+
"name"
|
|
2274
|
+
]
|
|
2275
|
+
});
|
|
2276
|
+
if ((step.kind === "single" || step.kind === "multi") && step.options === void 0) context.addIssue({
|
|
2277
|
+
code: "custom",
|
|
2278
|
+
message: `Step "${step.id}" requires options`,
|
|
2279
|
+
path: [
|
|
2280
|
+
"steps",
|
|
2281
|
+
index,
|
|
2282
|
+
"options"
|
|
2283
|
+
]
|
|
2284
|
+
});
|
|
2285
|
+
if (step.kind === "scale" && step.scale === void 0) context.addIssue({
|
|
2286
|
+
code: "custom",
|
|
2287
|
+
message: `Step "${step.id}" requires scale`,
|
|
2288
|
+
path: [
|
|
2289
|
+
"steps",
|
|
2290
|
+
index,
|
|
2291
|
+
"scale"
|
|
2292
|
+
]
|
|
2293
|
+
});
|
|
2294
|
+
if (step.kind === "fields" && step.fields === void 0) context.addIssue({
|
|
2295
|
+
code: "custom",
|
|
2296
|
+
message: `Step "${step.id}" requires fields`,
|
|
2297
|
+
path: [
|
|
2298
|
+
"steps",
|
|
2299
|
+
index,
|
|
2300
|
+
"fields"
|
|
2301
|
+
]
|
|
2302
|
+
});
|
|
2303
|
+
});
|
|
2304
|
+
const knownIds = new Set(definition.steps.map((step) => step.id));
|
|
2305
|
+
const checkNav = (nav, path) => {
|
|
2306
|
+
if (nav?.kind === "goto" && !knownIds.has(nav.target)) context.addIssue({
|
|
2307
|
+
code: "custom",
|
|
2308
|
+
message: `Unknown goto target: ${nav.target}`,
|
|
2309
|
+
path
|
|
2310
|
+
});
|
|
2311
|
+
};
|
|
2312
|
+
definition.steps.forEach((step, index) => {
|
|
2313
|
+
checkNav(step.nav, [
|
|
2314
|
+
"steps",
|
|
2315
|
+
index,
|
|
2316
|
+
"nav"
|
|
2317
|
+
]);
|
|
2318
|
+
for (const [optionIndex, option] of (step.options ?? []).entries()) checkNav(option.nav, [
|
|
2319
|
+
"steps",
|
|
2320
|
+
index,
|
|
2321
|
+
"options",
|
|
2322
|
+
optionIndex,
|
|
2323
|
+
"nav"
|
|
2324
|
+
]);
|
|
2325
|
+
});
|
|
2326
|
+
});
|
|
2327
|
+
function parseSurveyDefinition(input) {
|
|
2328
|
+
return surveyDefinitionSchema.parse(input);
|
|
2329
|
+
}
|
|
2330
|
+
//#endregion
|
|
2331
|
+
export { CRM_CONTACT_KEYS, VISITOR_ID_STORAGE_KEY, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildBasinFormData, buildContext, buildCounterPayload, buildFcrmUtms, buildSurveySnapshot, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, computeProgress, createAttemptStore, createLifecycleBus, createMemoryCache, createSessionStore, createStepsStore, createSurveyEngine, createTrackingConsumer, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, deriveQuizInputs, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, firstDefined, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getOrCreateVisitorId, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, hashSurveySnapshot, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, mapTrackingEvent, matchesFormat, mergeAnswers, normalizeResult, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, parseSurveyDefinition, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, snapshot, surveyDefinitionSchema, unknownResult, updateUrlWithParams, validators };
|
|
1570
2332
|
|
|
1571
2333
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reform-society/agera-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "DOM-free core logic for Agera",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
6
7
|
"main": "dist/index.js",
|
|
7
8
|
"types": "dist/index.d.ts",
|
|
8
9
|
"exports": {
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"loglevel": "^1.9.2",
|
|
45
|
-
"nanostores": "^1.5.2"
|
|
46
|
+
"nanostores": "^1.5.2",
|
|
47
|
+
"zod": "^4.1.0"
|
|
46
48
|
}
|
|
47
49
|
}
|