@reform-society/agera-core 0.5.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 +276 -83
- package/dist/index.js +837 -40
- 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,230 @@ 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
|
|
364
|
+
//#region src/lifecycle.d.ts
|
|
365
|
+
type NormalizedResult = {
|
|
366
|
+
delivery: "observed" | "unobserved";
|
|
367
|
+
operation: string;
|
|
368
|
+
operationStatus: "acknowledged" | "pending" | "completed" | "rejected" | "unknown";
|
|
369
|
+
contactStatus: "created" | "updated" | "unknown";
|
|
370
|
+
contactEvidence: "provider" | "inferred" | "unknown";
|
|
371
|
+
paymentStatus: "unknown" | "pending" | "succeeded" | "failed";
|
|
372
|
+
paymentEvidence: "provider" | "inferred" | "unknown";
|
|
373
|
+
nextAction?: {
|
|
374
|
+
type: "redirect" | "poll" | "verify";
|
|
375
|
+
url?: string;
|
|
376
|
+
};
|
|
377
|
+
outcome?: string;
|
|
378
|
+
extensions?: Record<string, unknown>;
|
|
379
|
+
};
|
|
380
|
+
type EventSourceKind = "form-submit" | "element-action";
|
|
381
|
+
type EventSource = {
|
|
382
|
+
client: string;
|
|
383
|
+
integration?: string;
|
|
384
|
+
kind?: EventSourceKind;
|
|
385
|
+
formId?: string;
|
|
386
|
+
triggerId?: string;
|
|
387
|
+
pagePath: string;
|
|
388
|
+
};
|
|
389
|
+
type RequestSnapshot = {
|
|
390
|
+
fields: Record<string, unknown>;
|
|
391
|
+
context: Record<string, unknown>;
|
|
392
|
+
config: Record<string, unknown>;
|
|
393
|
+
method: string;
|
|
394
|
+
delivery: "wait" | "background";
|
|
395
|
+
body?: unknown;
|
|
396
|
+
};
|
|
397
|
+
type EventEnvelope = {
|
|
398
|
+
schemaVersion: 1;
|
|
399
|
+
eventId: string;
|
|
400
|
+
occurredAt: string;
|
|
401
|
+
source: EventSource;
|
|
402
|
+
context: Record<string, unknown>;
|
|
403
|
+
result: NormalizedResult;
|
|
404
|
+
submissionId?: string;
|
|
405
|
+
flowId?: string;
|
|
406
|
+
attemptId?: string;
|
|
407
|
+
conversionId?: string;
|
|
408
|
+
request?: RequestSnapshot;
|
|
409
|
+
};
|
|
410
|
+
type LifecycleEvent = EventEnvelope & ({
|
|
411
|
+
type: "crm.dispatched";
|
|
412
|
+
submissionId: string;
|
|
413
|
+
request: RequestSnapshot;
|
|
414
|
+
} | {
|
|
415
|
+
type: "crm.response";
|
|
416
|
+
submissionId: string;
|
|
417
|
+
request: RequestSnapshot;
|
|
418
|
+
response: {
|
|
419
|
+
httpStatus: number;
|
|
420
|
+
raw: unknown;
|
|
421
|
+
};
|
|
422
|
+
} | {
|
|
423
|
+
type: "crm.failed" | "crm.cancelled";
|
|
424
|
+
submissionId: string;
|
|
425
|
+
request: RequestSnapshot;
|
|
426
|
+
} | {
|
|
427
|
+
type: "element.entered" | "element.clicked" | "element.viewed" | "step.changed";
|
|
428
|
+
} | {
|
|
429
|
+
type: "flow.started" | "flow.returned" | "flow.verified" | "flow.failed";
|
|
430
|
+
flowId: string;
|
|
431
|
+
attemptId: string;
|
|
432
|
+
});
|
|
433
|
+
type LifecycleType = LifecycleEvent["type"];
|
|
434
|
+
declare function unknownResult(delivery?: NormalizedResult["delivery"]): NormalizedResult;
|
|
435
|
+
/** Serializable copies omit functions, DOM handles, and cyclic references. */
|
|
436
|
+
declare function snapshot<T>(value: T): T;
|
|
437
|
+
declare function createLifecycleBus(onError?: () => void): {
|
|
438
|
+
on: <T extends LifecycleType>(type: T | "*", listener: (event: LifecycleEvent & {
|
|
439
|
+
type: T;
|
|
440
|
+
}) => unknown) => () => void;
|
|
441
|
+
emit: (event: LifecycleEvent) => void;
|
|
442
|
+
clear(): void;
|
|
443
|
+
};
|
|
444
|
+
declare function normalizeResult(value: unknown, httpStatus?: number): NormalizedResult;
|
|
445
|
+
//#endregion
|
|
221
446
|
//#region src/crm-types.d.ts
|
|
222
447
|
type CounterUpdateConfig = {
|
|
223
448
|
name: string;
|
|
@@ -303,12 +528,14 @@ type CrmOutcome = {
|
|
|
303
528
|
status: "succeeded";
|
|
304
529
|
httpStatus: number;
|
|
305
530
|
response: Record<string, unknown> | null;
|
|
531
|
+
normalized?: NormalizedResult;
|
|
306
532
|
} | {
|
|
307
533
|
status: "accepted";
|
|
308
534
|
} | {
|
|
309
535
|
status: "rejected";
|
|
310
536
|
httpStatus: number;
|
|
311
537
|
response: Record<string, unknown> | null;
|
|
538
|
+
normalized?: NormalizedResult;
|
|
312
539
|
} | {
|
|
313
540
|
status: "failed";
|
|
314
541
|
cause: unknown;
|
|
@@ -321,88 +548,6 @@ declare function executeCrm(gateway: CrmGateway, command: CrmCommand, options?:
|
|
|
321
548
|
signal?: AbortSignal;
|
|
322
549
|
}): Promise<CrmOutcome>;
|
|
323
550
|
//#endregion
|
|
324
|
-
//#region src/lifecycle.d.ts
|
|
325
|
-
type NormalizedResult = {
|
|
326
|
-
delivery: "observed" | "unobserved";
|
|
327
|
-
operation: string;
|
|
328
|
-
operationStatus: "acknowledged" | "pending" | "completed" | "rejected" | "unknown";
|
|
329
|
-
contactStatus: "created" | "updated" | "unknown";
|
|
330
|
-
contactEvidence: "provider" | "inferred" | "unknown";
|
|
331
|
-
paymentStatus: "unknown" | "pending" | "succeeded" | "failed";
|
|
332
|
-
paymentEvidence: "provider" | "inferred" | "unknown";
|
|
333
|
-
nextAction?: {
|
|
334
|
-
type: "redirect" | "poll" | "verify";
|
|
335
|
-
url?: string;
|
|
336
|
-
};
|
|
337
|
-
outcome?: string;
|
|
338
|
-
extensions?: Record<string, unknown>;
|
|
339
|
-
};
|
|
340
|
-
type EventSourceKind = "form-submit" | "element-action";
|
|
341
|
-
type EventSource = {
|
|
342
|
-
client: string;
|
|
343
|
-
integration?: string;
|
|
344
|
-
kind?: EventSourceKind;
|
|
345
|
-
formId?: string;
|
|
346
|
-
triggerId?: string;
|
|
347
|
-
pagePath: string;
|
|
348
|
-
};
|
|
349
|
-
type RequestSnapshot = {
|
|
350
|
-
fields: Record<string, unknown>;
|
|
351
|
-
context: Record<string, unknown>;
|
|
352
|
-
config: Record<string, unknown>;
|
|
353
|
-
method: string;
|
|
354
|
-
delivery: "wait" | "background";
|
|
355
|
-
body?: unknown;
|
|
356
|
-
};
|
|
357
|
-
type EventEnvelope = {
|
|
358
|
-
schemaVersion: 1;
|
|
359
|
-
eventId: string;
|
|
360
|
-
occurredAt: string;
|
|
361
|
-
source: EventSource;
|
|
362
|
-
context: Record<string, unknown>;
|
|
363
|
-
result: NormalizedResult;
|
|
364
|
-
submissionId?: string;
|
|
365
|
-
flowId?: string;
|
|
366
|
-
attemptId?: string;
|
|
367
|
-
conversionId?: string;
|
|
368
|
-
request?: RequestSnapshot;
|
|
369
|
-
};
|
|
370
|
-
type LifecycleEvent = EventEnvelope & ({
|
|
371
|
-
type: "crm.dispatched";
|
|
372
|
-
submissionId: string;
|
|
373
|
-
request: RequestSnapshot;
|
|
374
|
-
} | {
|
|
375
|
-
type: "crm.response";
|
|
376
|
-
submissionId: string;
|
|
377
|
-
request: RequestSnapshot;
|
|
378
|
-
response: {
|
|
379
|
-
httpStatus: number;
|
|
380
|
-
raw: unknown;
|
|
381
|
-
};
|
|
382
|
-
} | {
|
|
383
|
-
type: "crm.failed" | "crm.cancelled";
|
|
384
|
-
submissionId: string;
|
|
385
|
-
request: RequestSnapshot;
|
|
386
|
-
} | {
|
|
387
|
-
type: "element.entered" | "element.clicked" | "element.viewed" | "step.changed";
|
|
388
|
-
} | {
|
|
389
|
-
type: "flow.started" | "flow.returned" | "flow.verified" | "flow.failed";
|
|
390
|
-
flowId: string;
|
|
391
|
-
attemptId: string;
|
|
392
|
-
});
|
|
393
|
-
type LifecycleType = LifecycleEvent["type"];
|
|
394
|
-
declare function unknownResult(delivery?: NormalizedResult["delivery"]): NormalizedResult;
|
|
395
|
-
/** Serializable copies omit functions, DOM handles, and cyclic references. */
|
|
396
|
-
declare function snapshot<T>(value: T): T;
|
|
397
|
-
declare function createLifecycleBus(onError?: () => void): {
|
|
398
|
-
on: <T extends LifecycleType>(type: T | "*", listener: (event: LifecycleEvent & {
|
|
399
|
-
type: T;
|
|
400
|
-
}) => unknown) => () => void;
|
|
401
|
-
emit: (event: LifecycleEvent) => void;
|
|
402
|
-
clear(): void;
|
|
403
|
-
};
|
|
404
|
-
declare function normalizeResult(value: unknown, httpStatus?: number): NormalizedResult;
|
|
405
|
-
//#endregion
|
|
406
551
|
//#region src/tracking.d.ts
|
|
407
552
|
type ValueResolver = string | number | boolean | null | undefined | ValueResolver[] | {
|
|
408
553
|
[key: string]: ValueResolver;
|
|
@@ -571,6 +716,7 @@ type CrmFetchOptions = {
|
|
|
571
716
|
sourceUrl?: string;
|
|
572
717
|
deviceType?: DeviceType;
|
|
573
718
|
method?: "POST" | "PATCH" | "PUT";
|
|
719
|
+
signal?: AbortSignal;
|
|
574
720
|
};
|
|
575
721
|
declare function crmFetch(url: string, payload: Record<string, unknown> | object, options?: CrmFetchOptions): Promise<CrmFetchResult>;
|
|
576
722
|
//#endregion
|
|
@@ -615,5 +761,52 @@ declare function assembleCrmRequest({
|
|
|
615
761
|
//#region src/polling.d.ts
|
|
616
762
|
declare function matchesFormat(example: unknown, value: unknown): boolean;
|
|
617
763
|
//#endregion
|
|
618
|
-
|
|
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 };
|
|
619
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,39 +755,52 @@ async function fetchBasinJWT(endpoint, storage, storageKey, options = {}) {
|
|
|
754
755
|
}
|
|
755
756
|
}
|
|
756
757
|
//#endregion
|
|
757
|
-
//#region src/
|
|
758
|
-
function
|
|
759
|
-
|
|
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));
|
|
760
773
|
}
|
|
761
|
-
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
httpStatus: status,
|
|
781
|
-
response: data
|
|
782
|
-
};
|
|
783
|
-
} catch (error) {
|
|
784
|
-
if (signal?.aborted || isAbortError(error)) return { status: "aborted" };
|
|
785
|
-
return {
|
|
786
|
-
status: "failed",
|
|
787
|
-
cause: error
|
|
788
|
-
};
|
|
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);
|
|
789
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;
|
|
790
804
|
}
|
|
791
805
|
//#endregion
|
|
792
806
|
//#region src/lifecycle.ts
|
|
@@ -894,6 +908,45 @@ function normalizeResult(value, httpStatus) {
|
|
|
894
908
|
return result;
|
|
895
909
|
}
|
|
896
910
|
//#endregion
|
|
911
|
+
//#region src/crm-submission.ts
|
|
912
|
+
function isAbortError$1(error) {
|
|
913
|
+
return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
|
|
914
|
+
}
|
|
915
|
+
async function executeCrm(gateway, command, options = {}) {
|
|
916
|
+
const { signal } = options;
|
|
917
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
918
|
+
try {
|
|
919
|
+
const result = await gateway({
|
|
920
|
+
client: command.client,
|
|
921
|
+
method: command.method ?? "POST",
|
|
922
|
+
delivery: command.delivery ?? "wait",
|
|
923
|
+
payload: command.payload,
|
|
924
|
+
signal
|
|
925
|
+
});
|
|
926
|
+
if (result.kind === "accepted") return { status: "accepted" };
|
|
927
|
+
const { ok, status, data, cancelled, normalized } = result.response;
|
|
928
|
+
if (cancelled) return { status: "aborted" };
|
|
929
|
+
const extra = normalized !== void 0 ? { normalized: normalizeResult(normalized, status) } : {};
|
|
930
|
+
return ok ? {
|
|
931
|
+
status: "succeeded",
|
|
932
|
+
httpStatus: status,
|
|
933
|
+
response: data,
|
|
934
|
+
...extra
|
|
935
|
+
} : {
|
|
936
|
+
status: "rejected",
|
|
937
|
+
httpStatus: status,
|
|
938
|
+
response: data,
|
|
939
|
+
...extra
|
|
940
|
+
};
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (signal?.aborted || isAbortError$1(error)) return { status: "aborted" };
|
|
943
|
+
return {
|
|
944
|
+
status: "failed",
|
|
945
|
+
cause: error
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
//#endregion
|
|
897
950
|
//#region src/tracking.ts
|
|
898
951
|
function firstDefined(values) {
|
|
899
952
|
return values.find((value) => value !== void 0 && value !== "");
|
|
@@ -1414,19 +1467,43 @@ function getOrCreateVisitorId(storage) {
|
|
|
1414
1467
|
//#endregion
|
|
1415
1468
|
//#region src/crm-fetch.ts
|
|
1416
1469
|
const noop = () => {};
|
|
1470
|
+
function isRecord$1(value) {
|
|
1471
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1472
|
+
}
|
|
1473
|
+
function isAbortError(error) {
|
|
1474
|
+
return isRecord$1(error) && error.name === "AbortError";
|
|
1475
|
+
}
|
|
1476
|
+
/** Unwrap the v1 lifecycle envelope `{ schemaVersion: 1, raw, result }` when present. */
|
|
1477
|
+
function unwrapEnvelope(raw, status) {
|
|
1478
|
+
if (isRecord$1(raw) && raw.schemaVersion === 1 && Object.hasOwn(raw, "raw") && isRecord$1(raw.result)) return {
|
|
1479
|
+
data: raw.raw,
|
|
1480
|
+
normalized: normalizeResult(raw.result, status)
|
|
1481
|
+
};
|
|
1482
|
+
return { data: raw };
|
|
1483
|
+
}
|
|
1417
1484
|
async function crmFetch(url, payload, options = {}) {
|
|
1418
|
-
const { waitForResponse = true, sourceUrl, deviceType, method = "POST" } = options;
|
|
1485
|
+
const { waitForResponse = true, sourceUrl, deviceType, method = "POST", signal } = options;
|
|
1419
1486
|
const body = JSON.stringify(payload);
|
|
1420
|
-
const headers = {
|
|
1487
|
+
const headers = {
|
|
1488
|
+
"Content-Type": "application/json",
|
|
1489
|
+
"X-Agera-Response-Version": "1"
|
|
1490
|
+
};
|
|
1421
1491
|
if (sourceUrl) headers["x-source"] = sourceUrl;
|
|
1422
1492
|
if (deviceType !== void 0) headers["x-device-type"] = deviceType;
|
|
1493
|
+
if (signal?.aborted) return {
|
|
1494
|
+
ok: false,
|
|
1495
|
+
status: 0,
|
|
1496
|
+
data: null,
|
|
1497
|
+
cancelled: true
|
|
1498
|
+
};
|
|
1423
1499
|
if (!waitForResponse) {
|
|
1424
1500
|
try {
|
|
1425
1501
|
fetch(url, {
|
|
1426
1502
|
method,
|
|
1427
1503
|
headers,
|
|
1428
1504
|
body,
|
|
1429
|
-
keepalive: true
|
|
1505
|
+
keepalive: true,
|
|
1506
|
+
signal
|
|
1430
1507
|
}).catch(noop);
|
|
1431
1508
|
} catch {}
|
|
1432
1509
|
return {
|
|
@@ -1439,18 +1516,25 @@ async function crmFetch(url, payload, options = {}) {
|
|
|
1439
1516
|
const response = await fetch(url, {
|
|
1440
1517
|
method,
|
|
1441
1518
|
headers,
|
|
1442
|
-
body
|
|
1519
|
+
body,
|
|
1520
|
+
signal
|
|
1443
1521
|
});
|
|
1444
|
-
let
|
|
1522
|
+
let raw = null;
|
|
1445
1523
|
try {
|
|
1446
|
-
|
|
1524
|
+
raw = await response.json();
|
|
1447
1525
|
} catch {}
|
|
1448
1526
|
return {
|
|
1449
1527
|
ok: response.ok,
|
|
1450
1528
|
status: response.status,
|
|
1451
|
-
|
|
1529
|
+
...unwrapEnvelope(raw, response.status)
|
|
1530
|
+
};
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
if (signal?.aborted || isAbortError(error)) return {
|
|
1533
|
+
ok: false,
|
|
1534
|
+
status: 0,
|
|
1535
|
+
data: null,
|
|
1536
|
+
cancelled: true
|
|
1452
1537
|
};
|
|
1453
|
-
} catch {
|
|
1454
1538
|
return {
|
|
1455
1539
|
ok: false,
|
|
1456
1540
|
status: 0,
|
|
@@ -1531,6 +1615,719 @@ function matchesFormat(example, value) {
|
|
|
1531
1615
|
return example === value;
|
|
1532
1616
|
}
|
|
1533
1617
|
//#endregion
|
|
1534
|
-
|
|
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 };
|
|
1535
2332
|
|
|
1536
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
|
}
|