@usefillo/core 0.7.0 → 0.9.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.js CHANGED
@@ -21,6 +21,12 @@ function valueEquals(value, target) {
21
21
  return Number(value) === target;
22
22
  return false;
23
23
  }
24
+ function conditionNumber(value) {
25
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
26
+ if (typeof value !== "string" || value.trim() === "") return null;
27
+ const parsed = Number(value);
28
+ return Number.isFinite(parsed) ? parsed : null;
29
+ }
24
30
  function evalCondition(cond, resolve) {
25
31
  const value = resolve(cond.fieldId);
26
32
  switch (cond.op) {
@@ -41,14 +47,20 @@ function evalCondition(cond, resolve) {
41
47
  if (Array.isArray(value)) return value.some((v) => v === cond.value);
42
48
  return false;
43
49
  case "gt":
44
- return typeof value === "number" && value > Number(cond.value);
45
- case "lt":
46
- return typeof value === "number" && value < Number(cond.value);
50
+ case "lt": {
51
+ const left = conditionNumber(value);
52
+ const right = conditionNumber(cond.value);
53
+ return left !== null && right !== null && (cond.op === "gt" ? left > right : left < right);
54
+ }
47
55
  }
48
56
  }
57
+ function conditionsMet(conds, resolve) {
58
+ if (conds.length === 0) return true;
59
+ return conds.every((cond) => evalCondition(cond, resolve));
60
+ }
49
61
  function blockVisibleWith(block, resolve) {
50
62
  if (!block.visibleIf || block.visibleIf.length === 0) return true;
51
- return block.visibleIf.every((cond) => evalCondition(cond, resolve));
63
+ return conditionsMet(block.visibleIf, resolve);
52
64
  }
53
65
  function makeResolver(data, scoped, visible) {
54
66
  return (fieldId) => scoped.has(fieldId) && !visible.has(fieldId) ? void 0 : data[fieldId];
@@ -74,6 +86,15 @@ function visibleBlocks(page, data) {
74
86
  const resolve = makeResolver(data, scoped, visibleFieldIds(fields, data));
75
87
  return page.blocks.filter((b) => blockVisibleWith(b, resolve));
76
88
  }
89
+ function visiblePageBlocks(form, page, data) {
90
+ const fields = allFields(form);
91
+ const scoped = new Set(fields.map((f) => f.id));
92
+ const formVisible = visibleFieldIds(fields, data);
93
+ const resolve = makeResolver(data, scoped, formVisible);
94
+ return page.blocks.filter(
95
+ (b) => isField(b) ? formVisible.has(b.id) : blockVisibleWith(b, resolve)
96
+ );
97
+ }
77
98
  function allFields(form) {
78
99
  return form.pages.flatMap((p) => p.blocks.filter(isField));
79
100
  }
@@ -81,13 +102,80 @@ function responseScopeValue(settings, data) {
81
102
  const field2 = settings.responseLimit?.scopeField;
82
103
  if (!field2) return null;
83
104
  const value = data[field2];
84
- return typeof value === "string" || typeof value === "number" ? String(value) : null;
105
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
106
+ if (typeof value !== "string") return null;
107
+ const normalized = value.trim();
108
+ return normalized || null;
85
109
  }
86
110
  function visibleFields(form, data) {
87
111
  const fields = allFields(form);
88
112
  const visible = visibleFieldIds(fields, data);
89
113
  return fields.filter((f) => visible.has(f.id));
90
114
  }
115
+ function formResolver(form, data) {
116
+ const fields = allFields(form);
117
+ const scoped = new Set(fields.map((f) => f.id));
118
+ return makeResolver(data, scoped, visibleFieldIds(fields, data));
119
+ }
120
+ function resolveNextPage(form, currentPageId, data) {
121
+ const page = form.pages.find((p) => p.id === currentPageId);
122
+ const rules = page?.next;
123
+ if (!rules || rules.length === 0) return { linear: true };
124
+ const resolve = formResolver(form, data);
125
+ for (const rule of rules) {
126
+ if (conditionsMet(rule.when, resolve)) {
127
+ return rule.to === "end" ? { end: true } : { to: rule.to };
128
+ }
129
+ }
130
+ return { linear: true };
131
+ }
132
+ function reachablePageSequence(form, data) {
133
+ const seq = [];
134
+ if (form.pages.length === 0) return seq;
135
+ const seen = /* @__PURE__ */ new Set();
136
+ let index = 0;
137
+ for (let steps = 0; steps <= form.pages.length; steps++) {
138
+ const page = form.pages[index];
139
+ if (!page || seen.has(page.id)) break;
140
+ seen.add(page.id);
141
+ seq.push(page.id);
142
+ const nav = resolveNextPage(form, page.id, data);
143
+ if ("end" in nav) break;
144
+ if ("to" in nav) {
145
+ const target = form.pages.findIndex((p) => p.id === nav.to);
146
+ if (target < 0) break;
147
+ index = target;
148
+ } else {
149
+ index += 1;
150
+ if (index >= form.pages.length) break;
151
+ }
152
+ }
153
+ return seq;
154
+ }
155
+ function reachablePageIds(form, data) {
156
+ return new Set(reachablePageSequence(form, data));
157
+ }
158
+ function isTerminalPage(form, pageId, data) {
159
+ if ("end" in resolveNextPage(form, pageId, data)) return true;
160
+ const seq = reachablePageSequence(form, data);
161
+ return seq.length > 0 && seq[seq.length - 1] === pageId;
162
+ }
163
+ function reachableFieldIds(form, data) {
164
+ const reachablePages = reachablePageIds(form, data);
165
+ const visible = visibleFieldIds(allFields(form), data);
166
+ const ids = /* @__PURE__ */ new Set();
167
+ for (const page of form.pages) {
168
+ if (!reachablePages.has(page.id)) continue;
169
+ for (const block of page.blocks) {
170
+ if (isField(block) && visible.has(block.id)) ids.add(block.id);
171
+ }
172
+ }
173
+ return ids;
174
+ }
175
+ function reachableFields(form, data) {
176
+ const ids = reachableFieldIds(form, data);
177
+ return allFields(form).filter((f) => ids.has(f.id));
178
+ }
91
179
 
92
180
  // src/validation.ts
93
181
  import * as z from "zod/mini";
@@ -370,6 +458,76 @@ function isPossiblePhone(value) {
370
458
  if (country?.lengths.length) return country.lengths.includes(national.length);
371
459
  return national.length >= 4;
372
460
  }
461
+ var PHONE_POPOVER_VIEWPORT_GAP = 8;
462
+ function positionPhonePopover(anchor, popover) {
463
+ if (!anchor || !popover || typeof window === "undefined") return "below";
464
+ const gap = PHONE_POPOVER_VIEWPORT_GAP;
465
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
466
+ const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
467
+ const anchorRect = anchor.getBoundingClientRect();
468
+ const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
469
+ const spaceAbove = Math.max(0, anchorRect.top - gap);
470
+ const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
471
+ const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
472
+ const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
473
+ const maxWidth = Math.max(0, viewportWidth - gap * 2);
474
+ popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
475
+ popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
476
+ popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
477
+ const width = Math.min(popover.offsetWidth, maxWidth);
478
+ let offsetX = 0;
479
+ const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
480
+ if (rightOverflow > 0) offsetX -= rightOverflow;
481
+ const leftOverflow = gap - (anchorRect.left + offsetX);
482
+ if (leftOverflow > 0) offsetX += leftOverflow;
483
+ popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
484
+ return placement;
485
+ }
486
+
487
+ // src/strings.ts
488
+ var REQUIRED_FIELD_MESSAGE = "This field is required";
489
+ var DEFAULT_STRINGS = {
490
+ back: "Back",
491
+ next: "Next",
492
+ submit: "Submit",
493
+ submitting: "Submitting\u2026",
494
+ uploading: "Uploading\u2026",
495
+ optional: " (optional)",
496
+ other: "Other",
497
+ otherPrompt: "Other \u2014 please specify",
498
+ otherPlaceholder: "Your answer",
499
+ choosePlaceholder: "Choose\u2026",
500
+ successTitle: "Thanks!",
501
+ successMessage: "Your response has been recorded.",
502
+ closed: "This form is no longer accepting responses.",
503
+ notLive: "This form isn't live yet.",
504
+ submitFailed: "This form can't submit right now. Please try again in a moment.",
505
+ loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
506
+ loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
507
+ loadFailed: "This form could not be loaded.",
508
+ renderFailed: "This form could not be rendered.",
509
+ resumeNotice: "Picked up where you left off.",
510
+ editNotice: "You're updating your earlier response.",
511
+ resumeStartOver: "Start over",
512
+ challengeUnavailable: "The verification check couldn't load. Refresh the page, or check that challenges.cloudflare.com isn't blocked."
513
+ };
514
+ var DEFAULT_FIELD_STRINGS = {
515
+ required: REQUIRED_FIELD_MESSAGE,
516
+ resumeLinkExpired: "That saved-progress link expired or was already used \u2014 start again below.",
517
+ alreadyAnswered: "You've already answered this form.",
518
+ uploadRetry: "Retry",
519
+ uploadsDisabled: "Uploads are disabled in preview",
520
+ uploadFailed: "Upload failed \u2014 try again",
521
+ dropzoneTitle: (multiple) => `Drop ${multiple ? "files" : "a file"} here or click to browse`,
522
+ dropzoneHint: (maxMb) => `Up to ${maxMb} MB per file`,
523
+ fileTooLarge: (maxMb) => `Larger than ${maxMb} MB limit`,
524
+ filesUploading: (count) => `Uploading ${count} ${count === 1 ? "file" : "files"}, please wait\u2026`,
525
+ uploadsFailed: (count) => `${count} ${count === 1 ? "upload" : "uploads"} failed.`,
526
+ filesUploaded: (count) => `${count} ${count === 1 ? "file" : "files"} uploaded.`
527
+ };
528
+ function resolveStrings(overrides) {
529
+ return overrides ? { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS, ...overrides } : { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS };
530
+ }
373
531
 
374
532
  // src/validation.ts
375
533
  var fileValueSchema = z.object({
@@ -389,7 +547,7 @@ function isEmpty(value) {
389
547
  }
390
548
  function validateField(field2, value) {
391
549
  if (isEmpty(value)) {
392
- return field2.required ? "This field is required" : null;
550
+ return field2.required ? REQUIRED_FIELD_MESSAGE : null;
393
551
  }
394
552
  switch (field2.kind) {
395
553
  case "short_text":
@@ -402,11 +560,16 @@ function validateField(field2, value) {
402
560
  return null;
403
561
  }
404
562
  case "email": {
563
+ if (typeof value !== "string") return "Enter a valid email address";
564
+ if (field2.maxLength && value.length > field2.maxLength)
565
+ return `Must be at most ${field2.maxLength} characters`;
405
566
  const r = z.email().safeParse(value);
406
567
  return r.success ? null : "Enter a valid email address";
407
568
  }
408
569
  case "url": {
409
570
  if (typeof value !== "string") return "Enter a valid URL (including https://)";
571
+ if (field2.maxLength && value.length > field2.maxLength)
572
+ return `Must be at most ${field2.maxLength} characters`;
410
573
  try {
411
574
  const url = new URL(value);
412
575
  if (url.protocol !== "http:" && url.protocol !== "https:") {
@@ -441,9 +604,13 @@ function validateField(field2, value) {
441
604
  const ids = new Set(field2.options.map((o) => o.id));
442
605
  const arr = value;
443
606
  let others = 0;
607
+ const seen = /* @__PURE__ */ new Set();
444
608
  for (const v of arr) {
609
+ if (typeof v !== "string") return "Pick valid options";
610
+ if (seen.has(v)) return "Pick each option only once";
611
+ seen.add(v);
445
612
  if (ids.has(v)) continue;
446
- if (field2.allowOther !== true || typeof v !== "string" || v.length === 0 || v.length > 500)
613
+ if (field2.allowOther !== true || v.length === 0 || v.length > 500)
447
614
  return "Pick valid options";
448
615
  if (++others > 1) return "Only one 'Other' answer is allowed";
449
616
  }
@@ -519,7 +686,7 @@ function validateField(field2, value) {
519
686
  }
520
687
  }
521
688
  function validateResponse(form, data) {
522
- const fields = visibleFields(form, data);
689
+ const fields = reachableFields(form, data);
523
690
  const errors = {};
524
691
  const cleaned = {};
525
692
  for (const field2 of fields) {
@@ -535,6 +702,7 @@ function validateResponse(form, data) {
535
702
  }
536
703
  function normalizeValue(field2, value) {
537
704
  if (value === void 0 || value === null) return value;
705
+ if (field2.kind === "custom") return value;
538
706
  if (field2.kind === "number" && typeof value === "string" && value.trim() !== "") {
539
707
  const n = Number(value);
540
708
  return Number.isNaN(n) ? value : n;
@@ -589,7 +757,11 @@ var blockSchema = z2.looseObject({
589
757
  var pageSchema = z2.object({
590
758
  id: idSchema,
591
759
  title: z2.optional(z2.string().check(z2.maxLength(500))),
592
- blocks: z2.array(blockSchema).check(z2.maxLength(500))
760
+ blocks: z2.array(blockSchema).check(z2.maxLength(500)),
761
+ // Optional conditional page flow. Kept as unknown here (this schema is strict,
762
+ // so it would otherwise be dropped at parse) and normalized by hand below once
763
+ // the page-id set is known — a jump to a missing page is dropped, never kept.
764
+ next: z2.optional(z2.unknown())
593
765
  });
594
766
  var schemaShape = z2.object({
595
767
  version: z2.literal(1),
@@ -600,7 +772,9 @@ var schemaShape = z2.object({
600
772
  });
601
773
  var MAX_SCHEMA_VERSION = 1;
602
774
  var FILLO_SCHEMA_VERSION = 1;
603
- var FILLO_SDK_VERSION = true ? "0.7.0" : "0.0.0-dev";
775
+ var FILLO_SDK_VERSION = true ? "0.9.0" : "0.0.0-dev";
776
+ var FILLO_MIN_SDK_VERSION = "0.4.0";
777
+ var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
604
778
  function str(value, max, fallback = "") {
605
779
  return typeof value === "string" ? value.trim().slice(0, max) : fallback;
606
780
  }
@@ -630,6 +804,14 @@ function options(value, max = 200) {
630
804
  return id && label ? [cleanObject({ id, label, icon })] : [];
631
805
  });
632
806
  }
807
+ function duplicateOptionId(items) {
808
+ const ids = /* @__PURE__ */ new Set();
809
+ for (const { id } of items) {
810
+ if (ids.has(id)) return id;
811
+ ids.add(id);
812
+ }
813
+ return void 0;
814
+ }
633
815
  function conditions(value) {
634
816
  if (!Array.isArray(value)) return void 0;
635
817
  const normalized = value.slice(0, 20).flatMap((item) => {
@@ -647,6 +829,23 @@ function conditions(value) {
647
829
  });
648
830
  return normalized.length ? normalized : void 0;
649
831
  }
832
+ function jumpRules(value, pageIds, sourceId, allowedFieldIds) {
833
+ if (!Array.isArray(value)) return void 0;
834
+ const rules = value.slice(0, 50).flatMap((item) => {
835
+ if (!item || typeof item !== "object") return [];
836
+ const rec = item;
837
+ const to = typeof rec.to === "string" ? rec.to : void 0;
838
+ if (!to || to !== "end" && !pageIds.has(to)) return [];
839
+ if (to === sourceId) return [];
840
+ const rawWhen = rec.when;
841
+ if (rawWhen !== void 0 && !Array.isArray(rawWhen)) return [];
842
+ const when2 = conditions(rawWhen);
843
+ if (Array.isArray(rawWhen) && rawWhen.length > 0 && when2 === void 0) return [];
844
+ if (when2 && when2.some((c) => !allowedFieldIds.has(c.fieldId))) return [];
845
+ return [{ when: when2 ?? [], to }];
846
+ });
847
+ return rules.length ? rules : void 0;
848
+ }
650
849
  function baseBlock(rec) {
651
850
  return {
652
851
  id: str(rec.id, 128),
@@ -690,6 +889,17 @@ function normalizeResponseLimit(value) {
690
889
  onRepeat
691
890
  };
692
891
  }
892
+ function normalizeTrust(value) {
893
+ if (!value || typeof value !== "object") return void 0;
894
+ const rec = value;
895
+ const unverified = rec.unverified === "allow" || rec.unverified === "quarantine" ? rec.unverified : void 0;
896
+ const challenge = rec.challenge === "turnstile" ? "turnstile" : void 0;
897
+ if (!unverified && !challenge) return void 0;
898
+ return {
899
+ ...unverified ? { unverified } : {},
900
+ ...challenge ? { challenge } : {}
901
+ };
902
+ }
693
903
  function normalizeSettings(value) {
694
904
  const rec = value && typeof value === "object" ? value : {};
695
905
  const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
@@ -701,6 +911,7 @@ function normalizeSettings(value) {
701
911
  redirectUrl: normalizeUrl(rec.redirectUrl),
702
912
  showProgress: bool(rec.showProgress),
703
913
  responseLimit: normalizeResponseLimit(rec.responseLimit),
914
+ trust: normalizeTrust(rec.trust),
704
915
  notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
705
916
  sendReceipt: bool(rec.sendReceipt),
706
917
  saveProgress: bool(rec.saveProgress),
@@ -769,17 +980,25 @@ function normalizeBlock(input) {
769
980
  case "signature":
770
981
  case "date":
771
982
  return cleanObject(baseField(rec, kind));
772
- case "rating":
773
- return cleanObject({ ...baseField(rec, kind), max: int(rec.max, 1, 10, 5) });
983
+ case "rating": {
984
+ const max = int(rec.max, 1, 10, 5);
985
+ return cleanObject({
986
+ ...baseField(rec, kind),
987
+ max,
988
+ insightsMetric: rec.insightsMetric === "csat" && max === 5 ? "csat" : void 0
989
+ });
990
+ }
774
991
  case "linear_scale": {
775
992
  const min = int(rec.min, 0, 9, 1);
776
993
  const max = int(rec.max, min + 1, 10, 10);
994
+ const insightsMetric = rec.insightsMetric === "nps" && min === 0 && max === 10 ? "nps" : rec.insightsMetric === "csat" && min === 1 && max === 5 ? "csat" : void 0;
777
995
  return cleanObject({
778
996
  ...baseField(rec, kind),
779
997
  min,
780
998
  max,
781
999
  minLabel: optionalStr(rec.minLabel, 100),
782
- maxLabel: optionalStr(rec.maxLabel, 100)
1000
+ maxLabel: optionalStr(rec.maxLabel, 100),
1001
+ insightsMetric
783
1002
  });
784
1003
  }
785
1004
  case "ranking": {
@@ -832,22 +1051,56 @@ function normalizeFormSchema(input) {
832
1051
  if (parsed.data.version > MAX_SCHEMA_VERSION) {
833
1052
  return { ok: false, error: `Unsupported schema version: ${parsed.data.version}` };
834
1053
  }
835
- const pages = parsed.data.pages.map((page) => {
1054
+ const rebuilt = parsed.data.pages.map((page) => {
836
1055
  const blocks = page.blocks.flatMap((raw) => {
837
1056
  const block = normalizeBlock(raw);
838
1057
  return block ? [block] : [];
839
1058
  });
1059
+ return { id: str(page.id, 128), title: optionalStr(page.title, 500), blocks, next: page.next };
1060
+ });
1061
+ const targetIds = new Set(rebuilt.map((p) => p.id));
1062
+ const priorFieldIds = /* @__PURE__ */ new Set();
1063
+ const pages = rebuilt.map((page) => {
1064
+ for (const block of page.blocks) {
1065
+ if (isField(block)) priorFieldIds.add(block.id);
1066
+ }
840
1067
  return cleanObject({
841
1068
  id: page.id,
842
- title: optionalStr(page.title, 500),
843
- blocks
1069
+ title: page.title,
1070
+ blocks: page.blocks,
1071
+ next: jumpRules(page.next, targetIds, page.id, new Set(priorFieldIds))
844
1072
  });
845
1073
  });
1074
+ const pageIds = /* @__PURE__ */ new Set();
846
1075
  const seen = /* @__PURE__ */ new Set();
847
1076
  for (const page of pages) {
1077
+ if (!page.id) return { ok: false, error: "Page ids cannot be blank" };
1078
+ if (pageIds.has(page.id)) return { ok: false, error: `Duplicate page id: ${page.id}` };
1079
+ pageIds.add(page.id);
848
1080
  for (const block of page.blocks) {
849
1081
  if (seen.has(block.id)) return { ok: false, error: `Duplicate block id: ${block.id}` };
850
1082
  seen.add(block.id);
1083
+ if (block.kind === "number" && block.min !== void 0 && block.max !== void 0 && block.min > block.max) {
1084
+ return { ok: false, error: `Field ${block.id} has min greater than max` };
1085
+ }
1086
+ if (block.kind === "select" || block.kind === "multi_select" || block.kind === "dropdown" || block.kind === "ranking") {
1087
+ const duplicate = duplicateOptionId(block.options);
1088
+ if (duplicate) {
1089
+ return { ok: false, error: `Duplicate option id in field ${block.id}: ${duplicate}` };
1090
+ }
1091
+ } else if (block.kind === "matrix") {
1092
+ const duplicateRow = duplicateOptionId(block.rows);
1093
+ if (duplicateRow) {
1094
+ return { ok: false, error: `Duplicate matrix row id in field ${block.id}: ${duplicateRow}` };
1095
+ }
1096
+ const duplicateColumn = duplicateOptionId(block.columns);
1097
+ if (duplicateColumn) {
1098
+ return {
1099
+ ok: false,
1100
+ error: `Duplicate matrix column id in field ${block.id}: ${duplicateColumn}`
1101
+ };
1102
+ }
1103
+ }
851
1104
  }
852
1105
  }
853
1106
  if (pages.every((page) => page.blocks.length === 0)) {
@@ -949,6 +1202,9 @@ function formatAnswer(field2, value) {
949
1202
  var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
950
1203
  var MASK = 256 - 256 % ALPHABET.length;
951
1204
  function createId(size = 12) {
1205
+ if (!Number.isSafeInteger(size) || size <= 0) {
1206
+ throw new RangeError("createId size must be a positive safe integer");
1207
+ }
952
1208
  const csprng = globalThis.crypto;
953
1209
  if (typeof csprng === "undefined" || typeof csprng.getRandomValues !== "function") {
954
1210
  throw new Error("createId requires Web Crypto (globalThis.crypto.getRandomValues)");
@@ -978,12 +1234,12 @@ var BLOCK_KIND_META = {
978
1234
  dropdown: { label: "Dropdown", hint: "Select from a list" },
979
1235
  checkbox: { label: "Yes / No", hint: "Single checkbox" },
980
1236
  rating: { label: "Rating", hint: "1\u20135 stars" },
981
- linear_scale: { label: "Linear scale", hint: "1\u201310 with end labels" },
1237
+ linear_scale: { label: "Linear scale", hint: "0\u201310 with end labels" },
982
1238
  ranking: { label: "Ranking", hint: "Order options by preference" },
983
1239
  matrix: { label: "Matrix", hint: "Grid of rows \xD7 choices" },
984
1240
  signature: { label: "Signature", hint: "Draw to sign" },
985
1241
  date: { label: "Date", hint: "Date picker" },
986
- file_upload: { label: "File upload", hint: "Huge files, resumable" },
1242
+ file_upload: { label: "File upload", hint: "Direct to connected storage" },
987
1243
  hidden: { label: "Hidden field", hint: "Filled from the URL" },
988
1244
  custom: { label: "Custom", hint: "Your own component (code only)" },
989
1245
  heading: { label: "Heading", hint: "Section title" },
@@ -1018,10 +1274,11 @@ function createBlock(kind) {
1018
1274
  id,
1019
1275
  kind,
1020
1276
  label: "How likely are you to recommend us?",
1021
- min: 1,
1277
+ min: 0,
1022
1278
  max: 10,
1023
- minLabel: "Not likely",
1024
- maxLabel: "Very likely"
1279
+ minLabel: "Not at all likely",
1280
+ maxLabel: "Extremely likely",
1281
+ insightsMetric: "nps"
1025
1282
  };
1026
1283
  case "ranking":
1027
1284
  return {
@@ -1137,12 +1394,21 @@ function assembleBlock(spec) {
1137
1394
  break;
1138
1395
  case "rating":
1139
1396
  block.max = clampInt(spec.max, 3, 10, 5);
1397
+ if (spec.insightsMetric === "csat" && block.max === 5) {
1398
+ block.insightsMetric = "csat";
1399
+ }
1140
1400
  break;
1141
1401
  case "linear_scale":
1142
1402
  block.min = spec.min === 0 ? 0 : 1;
1143
1403
  block.max = clampInt(spec.max, 2, 10, 10);
1144
1404
  if (spec.minLabel) block.minLabel = str2(spec.minLabel, 100);
1145
1405
  if (spec.maxLabel) block.maxLabel = str2(spec.maxLabel, 100);
1406
+ delete block.insightsMetric;
1407
+ if (spec.insightsMetric === "nps" && block.min === 0 && block.max === 10) {
1408
+ block.insightsMetric = "nps";
1409
+ } else if (spec.insightsMetric === "csat" && block.min === 1 && block.max === 5) {
1410
+ block.insightsMetric = "csat";
1411
+ }
1146
1412
  break;
1147
1413
  case "number":
1148
1414
  if (typeof spec.min === "number") block.min = spec.min;
@@ -1280,25 +1546,30 @@ var sha1Base64 = (bytes) => bytesToBase64(new Sha1().update(bytes).digest());
1280
1546
 
1281
1547
  // src/client.ts
1282
1548
  var FilloError = class extends Error {
1283
- constructor(message, status, retryAfterSec2) {
1549
+ constructor(message, status, retryAfterSec2, code) {
1284
1550
  super(message);
1285
1551
  this.status = status;
1286
1552
  this.retryAfterSec = retryAfterSec2;
1553
+ this.code = code;
1287
1554
  this.name = "FilloError";
1288
1555
  }
1289
1556
  status;
1290
1557
  retryAfterSec;
1558
+ code;
1291
1559
  };
1292
1560
  function isFilloError(err) {
1293
1561
  return err instanceof Error && err.name === "FilloError";
1294
1562
  }
1295
- async function errorMessage(res, fallback) {
1563
+ async function errorDetails(res, fallback) {
1296
1564
  try {
1297
1565
  const body = await res.json();
1298
- if (typeof body.error === "string" && body.error) return body.error;
1566
+ return {
1567
+ message: typeof body.error === "string" && body.error ? body.error : fallback,
1568
+ code: typeof body.code === "string" && body.code ? body.code : void 0
1569
+ };
1299
1570
  } catch {
1300
1571
  }
1301
- return fallback;
1572
+ return { message: fallback };
1302
1573
  }
1303
1574
  function retryAfterSec(res) {
1304
1575
  const raw = Number(res.headers.get("retry-after"));
@@ -1306,11 +1577,31 @@ function retryAfterSec(res) {
1306
1577
  }
1307
1578
  var MAX_CHUNK_RETRIES = 3;
1308
1579
  var DEFAULT_TIMEOUT_MS = 3e4;
1580
+ var S3_PART_TIMEOUT_MS = 5 * 6e4;
1581
+ var UPLOAD_CONTROL_TIMEOUT_MS = 10 * 6e4;
1582
+ function waitForRetry(ms, signal) {
1583
+ signal?.throwIfAborted();
1584
+ return new Promise((resolve, reject) => {
1585
+ const aborted = () => {
1586
+ clearTimeout(timer);
1587
+ reject(signal?.reason);
1588
+ };
1589
+ const timer = setTimeout(() => {
1590
+ signal?.removeEventListener("abort", aborted);
1591
+ resolve();
1592
+ }, ms);
1593
+ signal?.addEventListener("abort", aborted, { once: true });
1594
+ });
1595
+ }
1596
+ function isControlRetryable(error, includeBusy = false) {
1597
+ const status = isFilloError(error) ? error.status ?? 0 : 0;
1598
+ return status === 0 || status === 503 || status === 504 || includeBusy && status === 409 && isFilloError(error) && error.retryAfterSec !== void 0;
1599
+ }
1309
1600
  function isRetryableStatus(status) {
1310
1601
  return status >= 500 || status === 429 || status === 408;
1311
1602
  }
1312
1603
  function asRequestError(err) {
1313
- if (err instanceof DOMException && err.name === "TimeoutError")
1604
+ if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "TimeoutError")
1314
1605
  return new FilloError("Request timed out", 0);
1315
1606
  return err;
1316
1607
  }
@@ -1381,8 +1672,8 @@ var FilloClient = class {
1381
1672
  throw asRequestError(err);
1382
1673
  }
1383
1674
  if (!res.ok) {
1384
- const message = await errorMessage(res, `Request failed: ${res.status}`);
1385
- throw new FilloError(message, res.status, retryAfterSec(res));
1675
+ const { message, code } = await errorDetails(res, `Request failed: ${res.status}`);
1676
+ throw new FilloError(message, res.status, retryAfterSec(res), code);
1386
1677
  }
1387
1678
  return await res.json();
1388
1679
  }
@@ -1393,8 +1684,10 @@ var FilloClient = class {
1393
1684
  return form;
1394
1685
  }
1395
1686
  /**
1396
- * Upsert a code-defined form into the workspace identified by the client's
1397
- * publishable key. Returns the canonical form id used for submissions.
1687
+ * Resolve a code-defined form through the workspace identified by the
1688
+ * client's publishable key. Depending on workspace policy, changed content
1689
+ * may be staged for review or resolved to the authoritative live snapshot.
1690
+ * Returns the canonical form id used for submissions.
1398
1691
  */
1399
1692
  syncForm(handle, schema, theme) {
1400
1693
  if (!this.key) {
@@ -1434,8 +1727,8 @@ var FilloClient = class {
1434
1727
  return { ok: false, errors: body2.errors };
1435
1728
  }
1436
1729
  if (!res.ok) {
1437
- const message = await errorMessage(res, `Submit failed: ${res.status}`);
1438
- throw new FilloError(message, res.status, retryAfterSec(res));
1730
+ const { message, code } = await errorDetails(res, `Submit failed: ${res.status}`);
1731
+ throw new FilloError(message, res.status, retryAfterSec(res), code);
1439
1732
  }
1440
1733
  const body = await res.json();
1441
1734
  return { ok: true, responseId: body.id, duplicate: body.duplicate, updated: body.updated };
@@ -1445,12 +1738,18 @@ var FilloClient = class {
1445
1738
  * used to prefill their answers for editing. Requires a VERIFIED identity
1446
1739
  * ({id, hash}); anything else 404s (returned as null), because an
1447
1740
  * unverified read would hand anyone's answers to any page script.
1741
+ *
1742
+ * `scopeValue` scopes the lookup for forms whose response limit is keyed to
1743
+ * a field (responseLimit.scopeField) — e.g. one response per article. When
1744
+ * the form is scoped, the server returns 404 unless the matching scope value
1745
+ * is sent, so a page loaded for article B never prefills article A's answers.
1746
+ * Optional and back-compatible: omit it for unscoped forms.
1448
1747
  */
1449
- async fetchOwnResponse(formId, respondent) {
1748
+ async fetchOwnResponse(formId, respondent, scopeValue) {
1450
1749
  try {
1451
1750
  return await this.json(
1452
1751
  `/forms/${encodeURIComponent(formId)}/respondent-response`,
1453
- { method: "POST", body: JSON.stringify({ respondent }) }
1752
+ { method: "POST", body: JSON.stringify({ respondent, scopeValue }) }
1454
1753
  );
1455
1754
  } catch (err) {
1456
1755
  if (isFilloError(err) && (err.status === 404 || err.status === 403)) return null;
@@ -1530,18 +1829,20 @@ var FilloClient = class {
1530
1829
  });
1531
1830
  }
1532
1831
  /** Current state of an upload session — used to resume after interruption. */
1533
- getUploadSession(sessionId, token) {
1832
+ getUploadSession(sessionId, token, signal = AbortSignal.timeout(UPLOAD_CONTROL_TIMEOUT_MS)) {
1534
1833
  return this.json(
1535
1834
  `/uploads/${encodeURIComponent(sessionId)}`,
1536
- token ? { headers: { "X-Fillo-Upload-Token": token } } : void 0
1835
+ {
1836
+ ...token ? { headers: { "X-Fillo-Upload-Token": token } } : {},
1837
+ signal
1838
+ }
1537
1839
  );
1538
1840
  }
1539
1841
  /**
1540
- * Resumable chunked upload. Creates (or resumes) a session, streams the file
1541
- * chunk by chunk with progress callbacks, and finalizes into a FileValue that
1542
- * goes into the response data. Survives flaky connections: each chunk is
1543
- * retried, and on repeated failure the true offset is re-queried so no byte
1544
- * is sent twice or skipped.
1842
+ * Provider-aware browser-direct upload. Creates a session, uses the storage
1843
+ * transport selected by the server, reports progress, and finalizes into a
1844
+ * FileValue. Resumable providers can continue an existing session; one-shot
1845
+ * transports are retried according to their own safe semantics.
1545
1846
  *
1546
1847
  * Depending on the form's storage settings the server picks a transport:
1547
1848
  * direct-to-Google-Drive resumable
@@ -1549,28 +1850,68 @@ var FilloClient = class {
1549
1850
  * form owner's Drive and never touch the Fillo server.
1550
1851
  */
1551
1852
  async uploadFile(formId, file, options2) {
1552
- const name = file instanceof File ? file.name : "upload.bin";
1553
- const mime = file.type || "application/octet-stream";
1853
+ const name = typeof File !== "undefined" && file instanceof File ? file.name : "upload.bin";
1854
+ const mime = (file.type || "application/octet-stream").trim().toLowerCase();
1554
1855
  let session;
1555
1856
  if (options2.sessionId) {
1556
- session = await this.getUploadSession(options2.sessionId, options2.uploadToken);
1857
+ const resumed = await this.getUploadSession(
1858
+ options2.sessionId,
1859
+ options2.uploadToken,
1860
+ this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
1861
+ );
1862
+ if (resumed.formId !== formId || resumed.fieldId !== options2.fieldId || resumed.fileName !== name || resumed.size !== file.size || resumed.mime.trim().toLowerCase() !== mime) {
1863
+ throw new FilloError("File does not match this upload session", 400);
1864
+ }
1865
+ session = resumed;
1866
+ session.token ||= options2.uploadToken;
1557
1867
  } else {
1558
- session = await this.json(
1868
+ const requestId = createId(24);
1869
+ const requestToken = createId(32);
1870
+ const createSession = () => this.json(
1559
1871
  `/forms/${encodeURIComponent(formId)}/uploads`,
1560
1872
  {
1561
1873
  method: "POST",
1562
1874
  body: JSON.stringify({
1875
+ requestId,
1876
+ uploadToken: requestToken,
1563
1877
  fieldId: options2.fieldId,
1564
1878
  fileName: name,
1565
1879
  size: file.size,
1566
1880
  mime
1567
- })
1881
+ }),
1882
+ signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
1568
1883
  }
1569
1884
  );
1885
+ const deadline = Date.now() + UPLOAD_CONTROL_TIMEOUT_MS;
1886
+ let backoffMs = 250;
1887
+ for (; ; ) {
1888
+ try {
1889
+ session = await createSession();
1890
+ break;
1891
+ } catch (error) {
1892
+ if (options2.signal?.aborted) throw error;
1893
+ if (!isControlRetryable(error, true)) throw error;
1894
+ const remaining = deadline - Date.now();
1895
+ if (remaining <= 0) throw error;
1896
+ await waitForRetry(
1897
+ Math.min(backoffMs, remaining),
1898
+ options2.signal
1899
+ );
1900
+ backoffMs = Math.min(backoffMs * 2, 5e3);
1901
+ }
1902
+ }
1570
1903
  }
1904
+ options2.onSession?.({
1905
+ sessionId: session.id,
1906
+ uploadToken: session.token
1907
+ });
1908
+ if (session.status === "complete" && session.file) return session.file;
1909
+ if (session.status === "aborted") throw new FilloError("This upload session was cancelled", 409);
1571
1910
  let completeBody;
1572
1911
  if (session.transport?.type === "gdrive") {
1573
1912
  await this.driveUploadLoop(session, session.transport.uploadUrl, file, options2);
1913
+ } else if (session.transport?.type === "s3-multipart") {
1914
+ await this.s3MultipartUploadLoop(session, file, options2);
1574
1915
  } else if (session.transport?.type === "s3-put") {
1575
1916
  await this.s3PutUpload(session.transport.uploadUrl, file, options2);
1576
1917
  } else if (session.transport?.type === "box") {
@@ -1578,14 +1919,52 @@ var FilloClient = class {
1578
1919
  } else {
1579
1920
  throw new FilloError("This form's storage doesn't support uploads \u2014 connect Drive, S3, or Box.");
1580
1921
  }
1581
- const done = await this.json(
1922
+ const finalize = () => this.json(
1582
1923
  `/uploads/${encodeURIComponent(session.id)}/complete`,
1583
1924
  {
1584
1925
  method: "POST",
1585
1926
  headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
1586
- ...completeBody ? { body: JSON.stringify(completeBody) } : {}
1927
+ ...completeBody ? { body: JSON.stringify(completeBody) } : {},
1928
+ signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
1587
1929
  }
1588
1930
  );
1931
+ let done;
1932
+ try {
1933
+ done = await finalize();
1934
+ } catch (error) {
1935
+ if (options2.signal?.aborted) throw error;
1936
+ if (!isControlRetryable(error)) throw error;
1937
+ const deadline = Date.now() + UPLOAD_CONTROL_TIMEOUT_MS;
1938
+ let backoffMs = 250;
1939
+ for (; ; ) {
1940
+ const reconciled = await this.getUploadSession(
1941
+ session.id,
1942
+ session.token,
1943
+ this.uploadSignal(
1944
+ options2,
1945
+ Math.max(1, deadline - Date.now())
1946
+ )
1947
+ );
1948
+ if (reconciled.status === "complete" && reconciled.file) {
1949
+ return reconciled.file;
1950
+ }
1951
+ if (reconciled.status === "aborted") throw error;
1952
+ try {
1953
+ done = await finalize();
1954
+ break;
1955
+ } catch (retryError) {
1956
+ if (options2.signal?.aborted) throw retryError;
1957
+ if (!isControlRetryable(retryError, true)) throw retryError;
1958
+ }
1959
+ const remaining = deadline - Date.now();
1960
+ if (remaining <= 0) throw error;
1961
+ await waitForRetry(
1962
+ Math.min(backoffMs, remaining),
1963
+ options2.signal
1964
+ );
1965
+ backoffMs = Math.min(backoffMs * 2, 5e3);
1966
+ }
1967
+ }
1589
1968
  if (!done.file) throw new FilloError("Upload completed but no file was returned");
1590
1969
  return done.file;
1591
1970
  }
@@ -1694,10 +2073,10 @@ var FilloClient = class {
1694
2073
  /**
1695
2074
  * Caller signal (if any) combined with a fresh per-request timeout. Created per
1696
2075
  * fetch so each retry attempt gets its own deadline; the caller's own abort
1697
- * still drives the resumable-upload cancel semantics.
2076
+ * still drives upload cancellation across every provider transport.
1698
2077
  */
1699
- uploadSignal(options2) {
1700
- const timeout = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
2078
+ uploadSignal(options2, timeoutMs = DEFAULT_TIMEOUT_MS) {
2079
+ const timeout = AbortSignal.timeout(timeoutMs);
1701
2080
  return options2.signal ? AbortSignal.any([options2.signal, timeout]) : timeout;
1702
2081
  }
1703
2082
  /** Retry a request with exponential backoff; never retries an aborted upload. */
@@ -1746,7 +2125,13 @@ var FilloClient = class {
1746
2125
  const match = range?.match(/bytes=0-(\d+)/);
1747
2126
  if (match) offset = Number(match[1]) + 1;
1748
2127
  else if (range == null) offset = 0;
1749
- else offset = (await this.getUploadSession(session.id, session.token)).uploadedBytes;
2128
+ else {
2129
+ offset = (await this.getUploadSession(
2130
+ session.id,
2131
+ session.token,
2132
+ this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
2133
+ )).uploadedBytes;
2134
+ }
1750
2135
  break;
1751
2136
  }
1752
2137
  if (res.ok) {
@@ -1759,7 +2144,11 @@ var FilloClient = class {
1759
2144
  attempt += 1;
1760
2145
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
1761
2146
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
1762
- const fresh = await this.getUploadSession(session.id, session.token);
2147
+ const fresh = await this.getUploadSession(
2148
+ session.id,
2149
+ session.token,
2150
+ this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
2151
+ );
1763
2152
  offset = fresh.uploadedBytes;
1764
2153
  if (offset >= file.size) break;
1765
2154
  end = Math.min(offset + session.chunkSize, file.size);
@@ -1776,6 +2165,80 @@ var FilloClient = class {
1776
2165
  }
1777
2166
  }
1778
2167
  }
2168
+ /**
2169
+ * S3-compatible multipart protocol. Fillo owns the provider upload id and is
2170
+ * the only party allowed to complete it; the browser receives only narrowly
2171
+ * scoped UploadPart URLs. That keeps uploads resumable and means an old or
2172
+ * slow browser request cannot materialize an object after server-side abort.
2173
+ */
2174
+ async s3MultipartUploadLoop(session, file, options2) {
2175
+ if (!Number.isSafeInteger(session.chunkSize) || session.chunkSize <= 0 || !Number.isSafeInteger(session.uploadedBytes) || session.uploadedBytes < 0 || session.uploadedBytes > file.size || session.uploadedBytes < file.size && session.uploadedBytes % session.chunkSize !== 0) {
2176
+ throw new FilloError("S3 upload session returned invalid progress", 502);
2177
+ }
2178
+ const report = makeReporter(file.size, options2);
2179
+ let offset = session.uploadedBytes;
2180
+ report(offset);
2181
+ while (offset < file.size) {
2182
+ options2.signal?.throwIfAborted();
2183
+ const partNumber = Math.floor(offset / session.chunkSize) + 1;
2184
+ if (partNumber > 1e4) {
2185
+ throw new FilloError("S3 upload needs too many parts", 413);
2186
+ }
2187
+ const end = Math.min(offset + session.chunkSize, file.size);
2188
+ const chunk = file.slice(offset, end);
2189
+ let attempt = 0;
2190
+ for (; ; ) {
2191
+ let uploadUrl;
2192
+ try {
2193
+ const signed = await this.json(
2194
+ `/uploads/${encodeURIComponent(session.id)}/parts`,
2195
+ {
2196
+ method: "POST",
2197
+ headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
2198
+ body: JSON.stringify({ partNumber }),
2199
+ signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
2200
+ }
2201
+ );
2202
+ if (typeof signed.uploadUrl !== "string" || !signed.uploadUrl) {
2203
+ throw new FilloError("S3 part URL was unavailable", 502);
2204
+ }
2205
+ uploadUrl = signed.uploadUrl;
2206
+ const res = await this.fetch(uploadUrl, {
2207
+ method: "PUT",
2208
+ body: chunk,
2209
+ signal: this.uploadSignal(options2, S3_PART_TIMEOUT_MS)
2210
+ });
2211
+ if (!res.ok) {
2212
+ throw new FilloError(`S3 part upload failed: ${res.status}`, res.status);
2213
+ }
2214
+ offset = end;
2215
+ report(offset);
2216
+ break;
2217
+ } catch (err) {
2218
+ if (options2.signal?.aborted) throw err;
2219
+ const fresh = await this.getUploadSession(
2220
+ session.id,
2221
+ session.token,
2222
+ this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
2223
+ );
2224
+ if (fresh.status === "aborted") {
2225
+ throw new FilloError("This upload session was cancelled", 409);
2226
+ }
2227
+ if (!Number.isSafeInteger(fresh.uploadedBytes) || fresh.uploadedBytes < offset || fresh.uploadedBytes > file.size || fresh.uploadedBytes < file.size && fresh.uploadedBytes % session.chunkSize !== 0) {
2228
+ throw new FilloError("S3 upload session returned invalid progress", 502);
2229
+ }
2230
+ if (fresh.uploadedBytes > offset) {
2231
+ offset = fresh.uploadedBytes;
2232
+ report(offset);
2233
+ break;
2234
+ }
2235
+ attempt += 1;
2236
+ if (attempt >= MAX_CHUNK_RETRIES) throw err;
2237
+ await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
2238
+ }
2239
+ }
2240
+ }
2241
+ }
1779
2242
  /**
1780
2243
  * S3-compatible single PUT to a presigned URL — bytes go straight to the
1781
2244
  * bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
@@ -1866,7 +2329,7 @@ function prefillFromParams(form, params) {
1866
2329
  }
1867
2330
  case "multi_select": {
1868
2331
  const ids = new Set(field2.options.map((o) => o.id));
1869
- const picked = raw.split(",").filter((v) => ids.has(v));
2332
+ const picked = [...new Set(raw.split(",").filter((v) => ids.has(v)))];
1870
2333
  if (picked.length > 0) data[field2.id] = picked;
1871
2334
  break;
1872
2335
  }
@@ -2002,10 +2465,20 @@ function isDraftGone(err) {
2002
2465
  return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
2003
2466
  }
2004
2467
  var DRAFT_DEBOUNCE_MS = 1500;
2468
+ var CHALLENGE_INCOMPLETE_MESSAGE = "Please complete the verification check, then submit.";
2469
+ var CHALLENGE_RETRY_MESSAGE = "That verification didn't go through. Please complete the check again and resubmit.";
2005
2470
  function submitFailureMessage(err) {
2006
2471
  if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
2007
2472
  return "Couldn't reach the server \u2014 check your connection and try again. If this keeps happening, a browser extension, firewall, or the site's Content-Security-Policy may be blocking the request.";
2008
2473
  }
2474
+ function syncResolutionFailureMessage(err) {
2475
+ if (isFilloError(err)) {
2476
+ const status = err.status ?? 0;
2477
+ const definitive = status > 0 && status < 500 && status !== 408 && status !== 429;
2478
+ if (definitive) return "This form is unavailable.";
2479
+ }
2480
+ return submitFailureMessage(err);
2481
+ }
2009
2482
  function createFormController(options2) {
2010
2483
  const { onChange, onSubmitted } = options2;
2011
2484
  const initial = normalizeFormSchema(options2.form);
@@ -2019,6 +2492,7 @@ function createFormController(options2) {
2019
2492
  let pageIndex = 0;
2020
2493
  let status = "idle";
2021
2494
  let restoredSubmission = false;
2495
+ let resumeLinkFailed = false;
2022
2496
  queueMicrotask(() => {
2023
2497
  if (typeof location !== "undefined" && location.search) {
2024
2498
  const params = Object.fromEntries(new URLSearchParams(location.search));
@@ -2054,7 +2528,11 @@ function createFormController(options2) {
2054
2528
  history.replaceState(null, "", location.pathname + location.search);
2055
2529
  } catch {
2056
2530
  }
2057
- }).catch(() => {
2531
+ }).catch((err) => {
2532
+ if (isDraftGone(err)) {
2533
+ resumeLinkFailed = true;
2534
+ notify();
2535
+ }
2058
2536
  }).finally(() => maybeRestoreDraft());
2059
2537
  }
2060
2538
  }
@@ -2069,6 +2547,7 @@ function createFormController(options2) {
2069
2547
  let sessionStarted = false;
2070
2548
  let resumedDraft = false;
2071
2549
  let editingPrevious = false;
2550
+ let submissionKind = "created";
2072
2551
  let editAttempted = false;
2073
2552
  let draftRef = null;
2074
2553
  let draftTimer = null;
@@ -2088,14 +2567,23 @@ function createFormController(options2) {
2088
2567
  pageIndex: clamped,
2089
2568
  pageCount,
2090
2569
  page,
2091
- blocks: visibleBlocks(page, data),
2570
+ // Whole-form visibility fixpoint (not just this page's fields) so a field
2571
+ // gated by a cross-page answer renders/validates exactly when the server
2572
+ // keeps it — never a field whose answer submit would silently drop.
2573
+ blocks: visiblePageBlocks(form, page, data),
2092
2574
  isFirstPage: clamped === 0,
2093
- isLastPage: clamped === pageCount - 1,
2575
+ // Terminal-aware: true when the current page is the last reachable page OR
2576
+ // a matched jump rule ends the form here — so the footer reads Submit and
2577
+ // its handler submits. Same shared engine the validator reaches with.
2578
+ isLastPage: isTerminalPage(form, page.id, data),
2094
2579
  uploading: uploadingFields.size > 0,
2095
2580
  submitError,
2096
2581
  restoredSubmission,
2097
2582
  resumedDraft,
2098
- editingPrevious
2583
+ editingPrevious,
2584
+ duplicateSubmission: submissionKind === "duplicate",
2585
+ updatedSubmission: submissionKind === "updated",
2586
+ resumeLinkFailed
2099
2587
  };
2100
2588
  }
2101
2589
  let snapshot = computeSnapshot();
@@ -2114,7 +2602,7 @@ function createFormController(options2) {
2114
2602
  const target = form.pages[index];
2115
2603
  if (!target) return {};
2116
2604
  const pageErrors = {};
2117
- for (const block of visibleBlocks(target, data)) {
2605
+ for (const block of visiblePageBlocks(form, target, data)) {
2118
2606
  if (!isField(block)) continue;
2119
2607
  const error = validateField(block, data[block.id]);
2120
2608
  if (error) pageErrors[block.id] = error;
@@ -2191,7 +2679,8 @@ function createFormController(options2) {
2191
2679
  if (!client || !formId || !respondent?.hash || status !== "idle") return;
2192
2680
  editAttempted = true;
2193
2681
  const c = client;
2194
- void c.fetchOwnResponse(formId, respondent).then((own) => {
2682
+ const scopeValue = responseScopeValue(form.settings, data) ?? void 0;
2683
+ void c.fetchOwnResponse(formId, respondent, scopeValue).then((own) => {
2195
2684
  if (!own || status !== "idle" || sessionStarted || resumedDraft) return;
2196
2685
  applyDraftSnapshot({ data: own.data, page: 0 });
2197
2686
  if (resumedDraft) {
@@ -2287,6 +2776,20 @@ function createFormController(options2) {
2287
2776
  else uploadingFields.delete(fieldId);
2288
2777
  notify();
2289
2778
  }
2779
+ function currentSeqPosition(seq) {
2780
+ if (seq.length === 0) return -1;
2781
+ const pageCount = form.pages.length;
2782
+ const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
2783
+ const currentId = form.pages[clamped]?.id;
2784
+ const exact = currentId === void 0 ? -1 : seq.indexOf(currentId);
2785
+ if (exact >= 0) return exact;
2786
+ let fallback = 0;
2787
+ for (let i = 0; i < seq.length; i++) {
2788
+ const idx = form.pages.findIndex((p) => p.id === seq[i]);
2789
+ if (idx >= 0 && idx <= clamped) fallback = i;
2790
+ }
2791
+ return fallback;
2792
+ }
2290
2793
  function next() {
2291
2794
  const pageCount = form.pages.length;
2292
2795
  const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
@@ -2298,14 +2801,31 @@ function createFormController(options2) {
2298
2801
  return;
2299
2802
  }
2300
2803
  }
2301
- pageIndex = Math.min(clamped + 1, pageCount - 1);
2804
+ const current = form.pages[clamped];
2805
+ const seq = reachablePageSequence(form, data);
2806
+ const pos = currentSeqPosition(seq);
2807
+ const terminal = (current ? isTerminalPage(form, current.id, data) : true) || pos < 0 || pos + 1 >= seq.length;
2808
+ if (terminal) {
2809
+ void submit();
2810
+ return;
2811
+ }
2812
+ const nextIndex = form.pages.findIndex((p) => p.id === seq[pos + 1]);
2813
+ pageIndex = nextIndex >= 0 ? nextIndex : Math.min(clamped + 1, pageCount - 1);
2302
2814
  if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
2303
2815
  notify();
2304
2816
  checkpointDraft();
2305
2817
  }
2306
2818
  function back() {
2307
2819
  errors = {};
2308
- pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
2820
+ const seq = reachablePageSequence(form, data);
2821
+ const pos = currentSeqPosition(seq);
2822
+ if (pos > 0) {
2823
+ const prevIndex = form.pages.findIndex((p) => p.id === seq[pos - 1]);
2824
+ pageIndex = prevIndex >= 0 ? prevIndex : Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
2825
+ } else if (seq.length > 0) {
2826
+ const firstIndex = form.pages.findIndex((p) => p.id === seq[0]);
2827
+ pageIndex = firstIndex >= 0 ? firstIndex : 0;
2828
+ }
2309
2829
  notify();
2310
2830
  checkpointDraft();
2311
2831
  }
@@ -2315,13 +2835,13 @@ function createFormController(options2) {
2315
2835
  if (!result.ok) {
2316
2836
  errors = result.errors;
2317
2837
  const firstBad = form.pages.findIndex(
2318
- (p) => visibleBlocks(p, data).some((b) => b.id in result.errors)
2838
+ (p) => visiblePageBlocks(form, p, data).some((b) => b.id in result.errors)
2319
2839
  );
2320
2840
  if (firstBad >= 0) pageIndex = firstBad;
2321
2841
  notify();
2322
2842
  return;
2323
2843
  }
2324
- if (client && !formId && options2.resolveFormId) {
2844
+ if (client && options2.resolveFormId) {
2325
2845
  status = "submitting";
2326
2846
  submitError = void 0;
2327
2847
  notify();
@@ -2329,7 +2849,7 @@ function createFormController(options2) {
2329
2849
  formId = await options2.resolveFormId();
2330
2850
  } catch (err) {
2331
2851
  status = "idle";
2332
- submitError = submitFailureMessage(err);
2852
+ submitError = syncResolutionFailureMessage(err);
2333
2853
  notify();
2334
2854
  throw err;
2335
2855
  }
@@ -2338,6 +2858,13 @@ function createFormController(options2) {
2338
2858
  const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
2339
2859
  const submissionKey = visitorKey ?? idempotencyKey;
2340
2860
  const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
2861
+ const challengeToken = options2.challengeRequired ? options2.getChallengeToken?.() : void 0;
2862
+ if (options2.challengeRequired && !challengeToken) {
2863
+ status = "idle";
2864
+ submitError = CHALLENGE_INCOMPLETE_MESSAGE;
2865
+ notify();
2866
+ return;
2867
+ }
2341
2868
  status = "submitting";
2342
2869
  submitError = void 0;
2343
2870
  notify();
@@ -2349,20 +2876,33 @@ function createFormController(options2) {
2349
2876
  surface: options2.surface ?? "headless",
2350
2877
  submissionKey,
2351
2878
  draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
2352
- respondent
2879
+ respondent,
2880
+ challengeToken
2353
2881
  });
2354
2882
  } catch (err) {
2355
2883
  status = "idle";
2884
+ if (isFilloError(err) && err.code === "challenge_failed") {
2885
+ submitError = CHALLENGE_RETRY_MESSAGE;
2886
+ options2.onChallengeFailed?.();
2887
+ notify();
2888
+ return;
2889
+ }
2356
2890
  submitError = submitFailureMessage(err);
2357
2891
  notify();
2358
2892
  throw err;
2359
2893
  }
2360
2894
  if (!res.ok) {
2361
2895
  errors = res.errors ?? {};
2896
+ const firstBad = form.pages.findIndex(
2897
+ (p) => visiblePageBlocks(form, p, data).some((b) => b.id in errors)
2898
+ );
2899
+ if (firstBad >= 0) pageIndex = firstBad;
2900
+ else submitError = "We couldn't submit \u2014 please review your answers.";
2362
2901
  status = "idle";
2363
2902
  notify();
2364
2903
  return;
2365
2904
  }
2905
+ submissionKind = res.duplicate ? "duplicate" : res.updated ? "updated" : "created";
2366
2906
  if (sessionId) client.reportProgress(sessionId, { completed: true });
2367
2907
  if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
2368
2908
  discardDraftState();
@@ -2449,14 +2989,13 @@ function isAutoSubmitBlock(block) {
2449
2989
  return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
2450
2990
  }
2451
2991
  function needsExplicitSubmit(visible) {
2452
- const interactive = visible.filter((b) => !(isField(b) && b.kind === "hidden"));
2453
- if (interactive.length === 0) return false;
2454
- if (interactive.length > 1) return true;
2992
+ const interactive = visible.filter((b) => isField(b) && b.kind !== "hidden");
2993
+ if (interactive.length !== 1) return true;
2455
2994
  return !isAutoSubmitBlock(interactive[0]);
2456
2995
  }
2457
2996
  function shouldAutoSubmit(field2, value, ctx) {
2458
2997
  if (ctx.form.settings.submitMode !== "auto") return false;
2459
- if (!ctx.isLastPage || ctx.status !== "idle" || ctx.uploading) return false;
2998
+ if (ctx.status !== "idle" || ctx.uploading) return false;
2460
2999
  const hasDiscreteAnswer = (() => {
2461
3000
  switch (field2.kind) {
2462
3001
  case "select":
@@ -2474,7 +3013,9 @@ function shouldAutoSubmit(field2, value, ctx) {
2474
3013
  if (!hasDiscreteAnswer) return false;
2475
3014
  if (value === ctx.data[field2.id]) return false;
2476
3015
  const nextData = { ...ctx.data, [field2.id]: value };
2477
- const interactive = visibleFields(ctx.form, nextData).filter((f) => f.kind !== "hidden");
3016
+ const page = ctx.form.pages.find((p) => p.blocks.some((b) => b.id === field2.id));
3017
+ if (!page || !isTerminalPage(ctx.form, page.id, nextData)) return false;
3018
+ const interactive = reachableFields(ctx.form, nextData).filter((f) => f.kind !== "hidden");
2478
3019
  if (interactive.length !== 1 || interactive[0]?.id !== field2.id) return false;
2479
3020
  return validateResponse(ctx.form, nextData).ok;
2480
3021
  }
@@ -2500,7 +3041,8 @@ var FILLO_SLOTS = [
2500
3041
  "footer",
2501
3042
  "button",
2502
3043
  "success",
2503
- "resume"
3044
+ "resume",
3045
+ "turnstile"
2504
3046
  ];
2505
3047
  var FILLO_DATA_ATTRS = {
2506
3048
  /** Slot name, on every slot element: `data-fillo="control"`. */
@@ -2549,35 +3091,6 @@ var FILLO_THEME_VARS = [
2549
3091
  { var: "--fillo-primary-contrast" }
2550
3092
  ];
2551
3093
 
2552
- // src/strings.ts
2553
- var DEFAULT_STRINGS = {
2554
- back: "Back",
2555
- next: "Next",
2556
- submit: "Submit",
2557
- submitting: "Submitting\u2026",
2558
- uploading: "Uploading\u2026",
2559
- optional: " (optional)",
2560
- other: "Other",
2561
- otherPrompt: "Other \u2014 please specify",
2562
- otherPlaceholder: "Your answer",
2563
- choosePlaceholder: "Choose\u2026",
2564
- successTitle: "Thanks!",
2565
- successMessage: "Your response has been recorded.",
2566
- closed: "This form is no longer accepting responses.",
2567
- notLive: "This form isn't live yet.",
2568
- submitFailed: "This form can't submit right now. Please try again in a moment.",
2569
- loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
2570
- loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
2571
- loadFailed: "This form could not be loaded.",
2572
- renderFailed: "This form could not be rendered.",
2573
- resumeNotice: "Picked up where you left off.",
2574
- editNotice: "You're updating your earlier response.",
2575
- resumeStartOver: "Start over"
2576
- };
2577
- function resolveStrings(overrides) {
2578
- return overrides ? { ...DEFAULT_STRINGS, ...overrides } : DEFAULT_STRINGS;
2579
- }
2580
-
2581
3094
  // src/define.ts
2582
3095
  function defineForm(def) {
2583
3096
  return {
@@ -2598,15 +3111,18 @@ function isCodeForm(form) {
2598
3111
  }
2599
3112
  var syncCache = /* @__PURE__ */ new Map();
2600
3113
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3114
+ function isRetryableSyncError(error) {
3115
+ if (!isFilloError(error)) return true;
3116
+ const status = error.status ?? 0;
3117
+ return status === 0 || status === 408 || status === 429 || status >= 500;
3118
+ }
2601
3119
  async function syncWithRetry(client, form) {
2602
3120
  let delay = 800;
2603
3121
  for (let attempt = 0; ; attempt++) {
2604
3122
  try {
2605
3123
  return await client.syncForm(form.id, form.schema, form.theme ?? null);
2606
3124
  } catch (err) {
2607
- const status = isFilloError(err) ? err.status ?? 0 : 0;
2608
- const retryable = !isFilloError(err) || status === 0 || status === 408 || status === 429 || status >= 500;
2609
- if (!retryable || attempt >= 2) throw err;
3125
+ if (!isRetryableSyncError(err) || attempt >= 2) throw err;
2610
3126
  const hinted = isFilloError(err) ? err.retryAfterSec : void 0;
2611
3127
  await sleep(Math.min(hinted ? hinted * 1e3 : delay * (1 + Math.random() * 0.5), 15e3));
2612
3128
  delay *= 2;
@@ -2618,17 +3134,32 @@ function contentHash(input) {
2618
3134
  for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) | 0;
2619
3135
  return (h >>> 0).toString(36);
2620
3136
  }
3137
+ function formSchemasEqual(left, right) {
3138
+ const canonicalize = (value) => {
3139
+ if (Array.isArray(value)) return value.map(canonicalize);
3140
+ if (value && typeof value === "object") {
3141
+ const record2 = value;
3142
+ return Object.fromEntries(
3143
+ Object.keys(record2).sort().map((key) => [key, canonicalize(record2[key])])
3144
+ );
3145
+ }
3146
+ return value;
3147
+ };
3148
+ return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
3149
+ }
3150
+ var syncTtlMs = (status) => status === "published" ? 36e5 : 6e4;
3151
+ var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError);
2621
3152
  function storageKey(client, handle) {
2622
- return `fillo:sync:${client.baseUrl}|${client.key}|${handle}`;
3153
+ return `fillo:sync:v3:${client.baseUrl}|${client.key}|${handle}`;
2623
3154
  }
2624
3155
  function readStoredSync(client, handle, hash) {
2625
3156
  try {
2626
3157
  const raw = globalThis.localStorage?.getItem(storageKey(client, handle));
2627
3158
  if (!raw) return null;
2628
3159
  const parsed = JSON.parse(raw);
2629
- if (typeof parsed.formId !== "string" || parsed.hash !== hash || typeof parsed.ts !== "number")
3160
+ if (typeof parsed.formId !== "string" || typeof parsed.slug !== "string" || parsed.hash !== hash || typeof parsed.ts !== "number" || parsed.status !== void 0 && parsed.status !== "draft" && parsed.status !== "published")
2630
3161
  return null;
2631
- const ttl = parsed.status === "published" ? 36e5 : 6e4;
3162
+ const ttl = syncTtlMs(parsed.status);
2632
3163
  if (Date.now() - parsed.ts > ttl) return null;
2633
3164
  return parsed;
2634
3165
  } catch {
@@ -2636,6 +3167,8 @@ function readStoredSync(client, handle, hash) {
2636
3167
  }
2637
3168
  }
2638
3169
  function writeStoredSync(client, handle, hash, r) {
3170
+ if (r.staged || r.resolvedSchema || r.syncError) return;
3171
+ if (r.challenge) return;
2639
3172
  try {
2640
3173
  globalThis.localStorage?.setItem(
2641
3174
  storageKey(client, handle),
@@ -2654,23 +3187,44 @@ function syncCodeForm(client, form, opts) {
2654
3187
  const content2 = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
2655
3188
  const hash = contentHash(content2);
2656
3189
  const key = `${client.baseUrl}:${client.key}:${form.id}:${hash}`;
2657
- let pending = syncCache.get(key);
2658
- if (pending && !opts?.bypassCache) return pending;
3190
+ const cached = syncCache.get(key);
3191
+ if (cached && !opts?.bypassCache) {
3192
+ if (cached.expiresAt === null || cached.expiresAt > Date.now()) return cached.promise;
3193
+ syncCache.delete(key);
3194
+ }
2659
3195
  if (!opts?.bypassCache) {
2660
3196
  const stored = readStoredSync(client, form.id, hash);
2661
3197
  if (stored) {
2662
- pending = Promise.resolve({ formId: stored.formId, slug: stored.slug, status: stored.status });
2663
- syncCache.set(key, pending);
2664
- return pending;
3198
+ const promise2 = Promise.resolve({
3199
+ formId: stored.formId,
3200
+ slug: stored.slug,
3201
+ status: stored.status
3202
+ });
3203
+ syncCache.set(key, {
3204
+ promise: promise2,
3205
+ expiresAt: stored.ts + syncTtlMs(stored.status)
3206
+ });
3207
+ return promise2;
2665
3208
  }
2666
3209
  }
2667
- pending = syncWithRetry(client, form).then((r) => {
2668
- writeStoredSync(client, form.id, hash, r);
2669
- return r;
2670
- });
2671
- syncCache.set(key, pending);
2672
- pending.catch(() => syncCache.delete(key));
2673
- return pending;
3210
+ const promise = syncWithRetry(client, form);
3211
+ const entry = { promise, expiresAt: null };
3212
+ syncCache.set(key, entry);
3213
+ promise.then(
3214
+ (result) => {
3215
+ if (syncCache.get(key) !== entry) return;
3216
+ if (isVolatileSync(result)) {
3217
+ syncCache.delete(key);
3218
+ return;
3219
+ }
3220
+ entry.expiresAt = Date.now() + syncTtlMs(result.status);
3221
+ writeStoredSync(client, form.id, hash, result);
3222
+ },
3223
+ () => {
3224
+ if (syncCache.get(key) === entry) syncCache.delete(key);
3225
+ }
3226
+ );
3227
+ return promise;
2674
3228
  }
2675
3229
 
2676
3230
  // src/jsx.ts
@@ -2710,8 +3264,8 @@ var JSX_BLOCK_SPECS = [
2710
3264
  field("MultiSelect", "multi_select", CHOICE_EXTRA, { optionChildren: true }),
2711
3265
  field("Dropdown", "dropdown", CHOICE_EXTRA, { optionChildren: true }),
2712
3266
  field("Checkbox", "checkbox", ["appearance"]),
2713
- field("Rating", "rating", ["max"]),
2714
- field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel"]),
3267
+ field("Rating", "rating", ["max", "insightsMetric"]),
3268
+ field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel", "insightsMetric"]),
2715
3269
  field("Ranking", "ranking", ["options"], { optionChildren: true }),
2716
3270
  field("Matrix", "matrix", ["rows", "columns"]),
2717
3271
  field("Signature", "signature", []),
@@ -2876,6 +3430,18 @@ function buildOption(el, parent, parentId) {
2876
3430
  if (props.icon !== void 0) option.icon = props.icon;
2877
3431
  return option;
2878
3432
  }
3433
+ function assertUniqueOptionIds(blockId, options2, label) {
3434
+ const optionIds = /* @__PURE__ */ new Set();
3435
+ for (const option of options2) {
3436
+ if (optionIds.has(option.id)) {
3437
+ throw new FilloJsxError(
3438
+ "DUPLICATE_ID",
3439
+ `Two ${label} in "${blockId}" share the id "${option.id}" \u2014 option ids are stored answer values and must be unique within the field`
3440
+ );
3441
+ }
3442
+ optionIds.add(option.id);
3443
+ }
3444
+ }
2879
3445
  function schemaFromJsx(children, meta) {
2880
3446
  const elements = flatten(children);
2881
3447
  const pageSpecs = elements.map((el) => ({ el, spec: classify(el) }));
@@ -2916,7 +3482,15 @@ function schemaFromJsx(children, meta) {
2916
3482
  pages.push({ id: "main", blocks });
2917
3483
  }
2918
3484
  const seen = /* @__PURE__ */ new Set();
3485
+ const pageIds = /* @__PURE__ */ new Set();
2919
3486
  for (const page of pages) {
3487
+ if (pageIds.has(page.id)) {
3488
+ throw new FilloJsxError(
3489
+ "DUPLICATE_ID",
3490
+ `Two pages share the id "${page.id}" \u2014 page ids must be unique within a form`
3491
+ );
3492
+ }
3493
+ pageIds.add(page.id);
2920
3494
  for (const block of page.blocks) {
2921
3495
  if (seen.has(block.id)) {
2922
3496
  throw new FilloJsxError(
@@ -2925,6 +3499,11 @@ function schemaFromJsx(children, meta) {
2925
3499
  );
2926
3500
  }
2927
3501
  seen.add(block.id);
3502
+ if ("options" in block) assertUniqueOptionIds(block.id, block.options, "options");
3503
+ if (block.kind === "matrix") {
3504
+ assertUniqueOptionIds(block.id, block.rows, "matrix rows");
3505
+ assertUniqueOptionIds(block.id, block.columns, "matrix columns");
3506
+ }
2928
3507
  }
2929
3508
  }
2930
3509
  return {
@@ -2999,9 +3578,12 @@ function pipeBlock(block, data, form) {
2999
3578
  export {
3000
3579
  BLOCK_KIND_META,
3001
3580
  CONTENT_KINDS,
3581
+ DEFAULT_FIELD_STRINGS,
3002
3582
  DEFAULT_STRINGS,
3003
3583
  DRAFT_KINDS,
3584
+ FILLO_CHALLENGE_MIN_SDK_VERSION,
3004
3585
  FILLO_DATA_ATTRS,
3586
+ FILLO_MIN_SDK_VERSION,
3005
3587
  FILLO_SCHEMA_VERSION,
3006
3588
  FILLO_SDK_VERSION,
3007
3589
  FILLO_SLOTS,
@@ -3012,10 +3594,13 @@ export {
3012
3594
  JSX_BLOCK_COMPONENTS,
3013
3595
  JSX_BLOCK_SPECS,
3014
3596
  PHONE_COUNTRIES,
3597
+ PHONE_POPOVER_VIEWPORT_GAP,
3598
+ REQUIRED_FIELD_MESSAGE,
3015
3599
  Sha1,
3016
3600
  allFields,
3017
3601
  assembleForm,
3018
3602
  codeFormFromJsx,
3603
+ conditionsMet,
3019
3604
  contentHash,
3020
3605
  countryByDialCode,
3021
3606
  countryByIso,
@@ -3028,6 +3613,7 @@ export {
3028
3613
  defineForm,
3029
3614
  digitsOnly,
3030
3615
  flagEmoji,
3616
+ formSchemasEqual,
3031
3617
  formatAnswer,
3032
3618
  formatNational,
3033
3619
  isAutoSubmitBlock,
@@ -3036,14 +3622,21 @@ export {
3036
3622
  isField,
3037
3623
  isFilloError,
3038
3624
  isPossiblePhone,
3625
+ isTerminalPage,
3039
3626
  needsExplicitSubmit,
3040
3627
  normalizeFormSchema,
3041
3628
  normalizeFormTheme,
3042
3629
  normalizeSettings,
3043
3630
  parsePhone,
3044
3631
  pipeBlock,
3632
+ positionPhonePopover,
3045
3633
  prefillFromParams,
3046
3634
  provisionWorkspace,
3635
+ reachableFieldIds,
3636
+ reachableFields,
3637
+ reachablePageIds,
3638
+ reachablePageSequence,
3639
+ resolveNextPage,
3047
3640
  resolveSlotClass,
3048
3641
  resolveStrings,
3049
3642
  resolveText,
@@ -3059,5 +3652,6 @@ export {
3059
3652
  validateResponse,
3060
3653
  visibleBlocks,
3061
3654
  visibleFields,
3655
+ visiblePageBlocks,
3062
3656
  when
3063
3657
  };