@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +276 -83
  2. package/dist/index.js +837 -40
  3. 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
- 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, 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, type SurveyOptions, type TrackingConfig, type Utms, VISITOR_ID_STORAGE_KEY, type ValidationRule, type Validators, type ValueResolver, type WeightedQuizResults, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createAttemptStore, createLifecycleBus, createMemoryCache, createSessionStore, createStepsStore, createTrackingConsumer, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, firstDefined, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getOrCreateVisitorId, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, mapTrackingEvent, matchesFormat, mergeAnswers, normalizeResult, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, snapshot, unknownResult, updateUrlWithParams, validators };
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/crm-submission.ts
758
- function isAbortError(error) {
759
- return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
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
- async function executeCrm(gateway, command, options = {}) {
762
- const { signal } = options;
763
- if (signal?.aborted) return { status: "aborted" };
764
- try {
765
- const result = await gateway({
766
- client: command.client,
767
- method: command.method ?? "POST",
768
- delivery: command.delivery ?? "wait",
769
- payload: command.payload,
770
- signal
771
- });
772
- if (result.kind === "accepted") return { status: "accepted" };
773
- const { ok, status, data } = result.response;
774
- return ok ? {
775
- status: "succeeded",
776
- httpStatus: status,
777
- response: data
778
- } : {
779
- status: "rejected",
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 = { "Content-Type": "application/json" };
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 data = null;
1522
+ let raw = null;
1445
1523
  try {
1446
- data = await response.json();
1524
+ raw = await response.json();
1447
1525
  } catch {}
1448
1526
  return {
1449
1527
  ok: response.ok,
1450
1528
  status: response.status,
1451
- data
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
- export { CRM_CONTACT_KEYS, VISITOR_ID_STORAGE_KEY, aggregateWeightedScores, answersChanged, assembleCrmRequest, buildContext, buildCounterPayload, buildFcrmUtms, calculateScoreResults, calculateSectionResults, calculateWinner, computeAutoTarget, createAttemptStore, createLifecycleBus, createMemoryCache, createSessionStore, createStepsStore, createTrackingConsumer, crmFetch, decodeJwtPayload, deriveBandResults, derivePassResults, deriveQuestionState, detectDeviceType, errorMessages, evaluateNextMap, evaluatePredicate, evaluateShowIf, executeCrm, extractUtms, fetchBasinJWT, fetchCounter, firstDefined, flattenObject, formatCounterValue, getErrorMessagesForLanguage, getOrCreateVisitorId, getPhoneExample, getPlacesMessagesForLanguage, getPostalExample, groupCrmAnswers, inferCounterName, isCounterResponse, isCrmContactKey, isJwtExpired, isSafeRedirectUrl, mapTrackingEvent, matchesFormat, mergeAnswers, normalizeResult, parseCounterUpdateAttr, parseInsertDirective, parseInsertDirectives, resolveValue, scoreExactQuestion, scorePartialQuestion, scoreQuiz, scoreQuizQuestions, serializeCrmJsonGroups, snapshot, unknownResult, updateUrlWithParams, validators };
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.5.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
  }