@usefillo/core 0.6.2 → 0.8.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,9 +47,11 @@ 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
  }
49
57
  function blockVisibleWith(block, resolve) {
@@ -74,9 +82,27 @@ function visibleBlocks(page, data) {
74
82
  const resolve = makeResolver(data, scoped, visibleFieldIds(fields, data));
75
83
  return page.blocks.filter((b) => blockVisibleWith(b, resolve));
76
84
  }
85
+ function visiblePageBlocks(form, page, data) {
86
+ const fields = allFields(form);
87
+ const scoped = new Set(fields.map((f) => f.id));
88
+ const formVisible = visibleFieldIds(fields, data);
89
+ const resolve = makeResolver(data, scoped, formVisible);
90
+ return page.blocks.filter(
91
+ (b) => isField(b) ? formVisible.has(b.id) : blockVisibleWith(b, resolve)
92
+ );
93
+ }
77
94
  function allFields(form) {
78
95
  return form.pages.flatMap((p) => p.blocks.filter(isField));
79
96
  }
97
+ function responseScopeValue(settings, data) {
98
+ const field2 = settings.responseLimit?.scopeField;
99
+ if (!field2) return null;
100
+ const value = data[field2];
101
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
102
+ if (typeof value !== "string") return null;
103
+ const normalized = value.trim();
104
+ return normalized || null;
105
+ }
80
106
  function visibleFields(form, data) {
81
107
  const fields = allFields(form);
82
108
  const visible = visibleFieldIds(fields, data);
@@ -364,6 +390,75 @@ function isPossiblePhone(value) {
364
390
  if (country?.lengths.length) return country.lengths.includes(national.length);
365
391
  return national.length >= 4;
366
392
  }
393
+ var PHONE_POPOVER_VIEWPORT_GAP = 8;
394
+ function positionPhonePopover(anchor, popover) {
395
+ if (!anchor || !popover || typeof window === "undefined") return "below";
396
+ const gap = PHONE_POPOVER_VIEWPORT_GAP;
397
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
398
+ const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
399
+ const anchorRect = anchor.getBoundingClientRect();
400
+ const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
401
+ const spaceAbove = Math.max(0, anchorRect.top - gap);
402
+ const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
403
+ const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
404
+ const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
405
+ const maxWidth = Math.max(0, viewportWidth - gap * 2);
406
+ popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
407
+ popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
408
+ popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
409
+ const width = Math.min(popover.offsetWidth, maxWidth);
410
+ let offsetX = 0;
411
+ const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
412
+ if (rightOverflow > 0) offsetX -= rightOverflow;
413
+ const leftOverflow = gap - (anchorRect.left + offsetX);
414
+ if (leftOverflow > 0) offsetX += leftOverflow;
415
+ popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
416
+ return placement;
417
+ }
418
+
419
+ // src/strings.ts
420
+ var REQUIRED_FIELD_MESSAGE = "This field is required";
421
+ var DEFAULT_STRINGS = {
422
+ back: "Back",
423
+ next: "Next",
424
+ submit: "Submit",
425
+ submitting: "Submitting\u2026",
426
+ uploading: "Uploading\u2026",
427
+ optional: " (optional)",
428
+ other: "Other",
429
+ otherPrompt: "Other \u2014 please specify",
430
+ otherPlaceholder: "Your answer",
431
+ choosePlaceholder: "Choose\u2026",
432
+ successTitle: "Thanks!",
433
+ successMessage: "Your response has been recorded.",
434
+ closed: "This form is no longer accepting responses.",
435
+ notLive: "This form isn't live yet.",
436
+ submitFailed: "This form can't submit right now. Please try again in a moment.",
437
+ loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
438
+ loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
439
+ loadFailed: "This form could not be loaded.",
440
+ renderFailed: "This form could not be rendered.",
441
+ resumeNotice: "Picked up where you left off.",
442
+ editNotice: "You're updating your earlier response.",
443
+ resumeStartOver: "Start over"
444
+ };
445
+ var DEFAULT_FIELD_STRINGS = {
446
+ required: REQUIRED_FIELD_MESSAGE,
447
+ resumeLinkExpired: "That saved-progress link expired or was already used \u2014 start again below.",
448
+ alreadyAnswered: "You've already answered this form.",
449
+ uploadRetry: "Retry",
450
+ uploadsDisabled: "Uploads are disabled in preview",
451
+ uploadFailed: "Upload failed \u2014 try again",
452
+ dropzoneTitle: (multiple) => `Drop ${multiple ? "files" : "a file"} here or click to browse`,
453
+ dropzoneHint: (maxMb) => `Up to ${maxMb} MB per file`,
454
+ fileTooLarge: (maxMb) => `Larger than ${maxMb} MB limit`,
455
+ filesUploading: (count) => `Uploading ${count} ${count === 1 ? "file" : "files"}, please wait\u2026`,
456
+ uploadsFailed: (count) => `${count} ${count === 1 ? "upload" : "uploads"} failed.`,
457
+ filesUploaded: (count) => `${count} ${count === 1 ? "file" : "files"} uploaded.`
458
+ };
459
+ function resolveStrings(overrides) {
460
+ return overrides ? { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS, ...overrides } : { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS };
461
+ }
367
462
 
368
463
  // src/validation.ts
369
464
  var fileValueSchema = z.object({
@@ -383,7 +478,7 @@ function isEmpty(value) {
383
478
  }
384
479
  function validateField(field2, value) {
385
480
  if (isEmpty(value)) {
386
- return field2.required ? "This field is required" : null;
481
+ return field2.required ? REQUIRED_FIELD_MESSAGE : null;
387
482
  }
388
483
  switch (field2.kind) {
389
484
  case "short_text":
@@ -396,11 +491,16 @@ function validateField(field2, value) {
396
491
  return null;
397
492
  }
398
493
  case "email": {
494
+ if (typeof value !== "string") return "Enter a valid email address";
495
+ if (field2.maxLength && value.length > field2.maxLength)
496
+ return `Must be at most ${field2.maxLength} characters`;
399
497
  const r = z.email().safeParse(value);
400
498
  return r.success ? null : "Enter a valid email address";
401
499
  }
402
500
  case "url": {
403
501
  if (typeof value !== "string") return "Enter a valid URL (including https://)";
502
+ if (field2.maxLength && value.length > field2.maxLength)
503
+ return `Must be at most ${field2.maxLength} characters`;
404
504
  try {
405
505
  const url = new URL(value);
406
506
  if (url.protocol !== "http:" && url.protocol !== "https:") {
@@ -435,9 +535,13 @@ function validateField(field2, value) {
435
535
  const ids = new Set(field2.options.map((o) => o.id));
436
536
  const arr = value;
437
537
  let others = 0;
538
+ const seen = /* @__PURE__ */ new Set();
438
539
  for (const v of arr) {
540
+ if (typeof v !== "string") return "Pick valid options";
541
+ if (seen.has(v)) return "Pick each option only once";
542
+ seen.add(v);
439
543
  if (ids.has(v)) continue;
440
- if (field2.allowOther !== true || typeof v !== "string" || v.length === 0 || v.length > 500)
544
+ if (field2.allowOther !== true || v.length === 0 || v.length > 500)
441
545
  return "Pick valid options";
442
546
  if (++others > 1) return "Only one 'Other' answer is allowed";
443
547
  }
@@ -529,6 +633,7 @@ function validateResponse(form, data) {
529
633
  }
530
634
  function normalizeValue(field2, value) {
531
635
  if (value === void 0 || value === null) return value;
636
+ if (field2.kind === "custom") return value;
532
637
  if (field2.kind === "number" && typeof value === "string" && value.trim() !== "") {
533
638
  const n = Number(value);
534
639
  return Number.isNaN(n) ? value : n;
@@ -594,7 +699,8 @@ var schemaShape = z2.object({
594
699
  });
595
700
  var MAX_SCHEMA_VERSION = 1;
596
701
  var FILLO_SCHEMA_VERSION = 1;
597
- var FILLO_SDK_VERSION = true ? "0.6.2" : "0.0.0-dev";
702
+ var FILLO_SDK_VERSION = true ? "0.8.0" : "0.0.0-dev";
703
+ var FILLO_MIN_SDK_VERSION = "0.4.0";
598
704
  function str(value, max, fallback = "") {
599
705
  return typeof value === "string" ? value.trim().slice(0, max) : fallback;
600
706
  }
@@ -624,6 +730,14 @@ function options(value, max = 200) {
624
730
  return id && label ? [cleanObject({ id, label, icon })] : [];
625
731
  });
626
732
  }
733
+ function duplicateOptionId(items) {
734
+ const ids = /* @__PURE__ */ new Set();
735
+ for (const { id } of items) {
736
+ if (ids.has(id)) return id;
737
+ ids.add(id);
738
+ }
739
+ return void 0;
740
+ }
627
741
  function conditions(value) {
628
742
  if (!Array.isArray(value)) return void 0;
629
743
  const normalized = value.slice(0, 20).flatMap((item) => {
@@ -668,10 +782,25 @@ function normalizeUrl(value) {
668
782
  return void 0;
669
783
  }
670
784
  }
785
+ function normalizeResponseLimit(value) {
786
+ if (!value || typeof value !== "object") return void 0;
787
+ const rec = value;
788
+ const by = rec.by === "browser" || rec.by === "field" || rec.by === "identify" ? rec.by : void 0;
789
+ if (!by) return void 0;
790
+ const field2 = by === "field" ? optionalStr(rec.field, 128) : void 0;
791
+ if (by === "field" && !field2) return void 0;
792
+ const scopeField = optionalStr(rec.scopeField, 128);
793
+ const onRepeat = rec.onRepeat === "update" && by === "identify" ? "update" : "keep";
794
+ return {
795
+ by,
796
+ ...field2 ? { field: field2 } : {},
797
+ ...scopeField ? { scopeField } : {},
798
+ onRepeat
799
+ };
800
+ }
671
801
  function normalizeSettings(value) {
672
802
  const rec = value && typeof value === "object" ? value : {};
673
803
  const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
674
- const submissionLimit = rec.submissionLimit === "once_per_visitor" || rec.submissionLimit === "multiple" ? rec.submissionLimit : void 0;
675
804
  return {
676
805
  submitMode,
677
806
  submitLabel: optionalStr(rec.submitLabel, 100),
@@ -679,9 +808,14 @@ function normalizeSettings(value) {
679
808
  successMessage: optionalStr(rec.successMessage, 5e3),
680
809
  redirectUrl: normalizeUrl(rec.redirectUrl),
681
810
  showProgress: bool(rec.showProgress),
682
- submissionLimit,
811
+ responseLimit: normalizeResponseLimit(rec.responseLimit),
683
812
  notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
684
- sendReceipt: bool(rec.sendReceipt)
813
+ sendReceipt: bool(rec.sendReceipt),
814
+ saveProgress: bool(rec.saveProgress),
815
+ draftAnswersVisible: bool(rec.draftAnswersVisible),
816
+ resumeEmails: bool(rec.resumeEmails),
817
+ resumeUrl: normalizeUrl(rec.resumeUrl),
818
+ draftDigest: bool(rec.draftDigest)
685
819
  };
686
820
  }
687
821
  function cleanObject(obj) {
@@ -743,17 +877,25 @@ function normalizeBlock(input) {
743
877
  case "signature":
744
878
  case "date":
745
879
  return cleanObject(baseField(rec, kind));
746
- case "rating":
747
- return cleanObject({ ...baseField(rec, kind), max: int(rec.max, 1, 10, 5) });
880
+ case "rating": {
881
+ const max = int(rec.max, 1, 10, 5);
882
+ return cleanObject({
883
+ ...baseField(rec, kind),
884
+ max,
885
+ insightsMetric: rec.insightsMetric === "csat" && max === 5 ? "csat" : void 0
886
+ });
887
+ }
748
888
  case "linear_scale": {
749
889
  const min = int(rec.min, 0, 9, 1);
750
890
  const max = int(rec.max, min + 1, 10, 10);
891
+ const insightsMetric = rec.insightsMetric === "nps" && min === 0 && max === 10 ? "nps" : rec.insightsMetric === "csat" && min === 1 && max === 5 ? "csat" : void 0;
751
892
  return cleanObject({
752
893
  ...baseField(rec, kind),
753
894
  min,
754
895
  max,
755
896
  minLabel: optionalStr(rec.minLabel, 100),
756
- maxLabel: optionalStr(rec.maxLabel, 100)
897
+ maxLabel: optionalStr(rec.maxLabel, 100),
898
+ insightsMetric
757
899
  });
758
900
  }
759
901
  case "ranking": {
@@ -812,16 +954,41 @@ function normalizeFormSchema(input) {
812
954
  return block ? [block] : [];
813
955
  });
814
956
  return cleanObject({
815
- id: page.id,
957
+ id: str(page.id, 128),
816
958
  title: optionalStr(page.title, 500),
817
959
  blocks
818
960
  });
819
961
  });
962
+ const pageIds = /* @__PURE__ */ new Set();
820
963
  const seen = /* @__PURE__ */ new Set();
821
964
  for (const page of pages) {
965
+ if (!page.id) return { ok: false, error: "Page ids cannot be blank" };
966
+ if (pageIds.has(page.id)) return { ok: false, error: `Duplicate page id: ${page.id}` };
967
+ pageIds.add(page.id);
822
968
  for (const block of page.blocks) {
823
969
  if (seen.has(block.id)) return { ok: false, error: `Duplicate block id: ${block.id}` };
824
970
  seen.add(block.id);
971
+ if (block.kind === "number" && block.min !== void 0 && block.max !== void 0 && block.min > block.max) {
972
+ return { ok: false, error: `Field ${block.id} has min greater than max` };
973
+ }
974
+ if (block.kind === "select" || block.kind === "multi_select" || block.kind === "dropdown" || block.kind === "ranking") {
975
+ const duplicate = duplicateOptionId(block.options);
976
+ if (duplicate) {
977
+ return { ok: false, error: `Duplicate option id in field ${block.id}: ${duplicate}` };
978
+ }
979
+ } else if (block.kind === "matrix") {
980
+ const duplicateRow = duplicateOptionId(block.rows);
981
+ if (duplicateRow) {
982
+ return { ok: false, error: `Duplicate matrix row id in field ${block.id}: ${duplicateRow}` };
983
+ }
984
+ const duplicateColumn = duplicateOptionId(block.columns);
985
+ if (duplicateColumn) {
986
+ return {
987
+ ok: false,
988
+ error: `Duplicate matrix column id in field ${block.id}: ${duplicateColumn}`
989
+ };
990
+ }
991
+ }
825
992
  }
826
993
  }
827
994
  if (pages.every((page) => page.blocks.length === 0)) {
@@ -923,6 +1090,9 @@ function formatAnswer(field2, value) {
923
1090
  var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
924
1091
  var MASK = 256 - 256 % ALPHABET.length;
925
1092
  function createId(size = 12) {
1093
+ if (!Number.isSafeInteger(size) || size <= 0) {
1094
+ throw new RangeError("createId size must be a positive safe integer");
1095
+ }
926
1096
  const csprng = globalThis.crypto;
927
1097
  if (typeof csprng === "undefined" || typeof csprng.getRandomValues !== "function") {
928
1098
  throw new Error("createId requires Web Crypto (globalThis.crypto.getRandomValues)");
@@ -952,12 +1122,12 @@ var BLOCK_KIND_META = {
952
1122
  dropdown: { label: "Dropdown", hint: "Select from a list" },
953
1123
  checkbox: { label: "Yes / No", hint: "Single checkbox" },
954
1124
  rating: { label: "Rating", hint: "1\u20135 stars" },
955
- linear_scale: { label: "Linear scale", hint: "1\u201310 with end labels" },
1125
+ linear_scale: { label: "Linear scale", hint: "0\u201310 with end labels" },
956
1126
  ranking: { label: "Ranking", hint: "Order options by preference" },
957
1127
  matrix: { label: "Matrix", hint: "Grid of rows \xD7 choices" },
958
1128
  signature: { label: "Signature", hint: "Draw to sign" },
959
1129
  date: { label: "Date", hint: "Date picker" },
960
- file_upload: { label: "File upload", hint: "Huge files, resumable" },
1130
+ file_upload: { label: "File upload", hint: "Direct to connected storage" },
961
1131
  hidden: { label: "Hidden field", hint: "Filled from the URL" },
962
1132
  custom: { label: "Custom", hint: "Your own component (code only)" },
963
1133
  heading: { label: "Heading", hint: "Section title" },
@@ -992,10 +1162,11 @@ function createBlock(kind) {
992
1162
  id,
993
1163
  kind,
994
1164
  label: "How likely are you to recommend us?",
995
- min: 1,
1165
+ min: 0,
996
1166
  max: 10,
997
- minLabel: "Not likely",
998
- maxLabel: "Very likely"
1167
+ minLabel: "Not at all likely",
1168
+ maxLabel: "Extremely likely",
1169
+ insightsMetric: "nps"
999
1170
  };
1000
1171
  case "ranking":
1001
1172
  return {
@@ -1049,8 +1220,7 @@ function createEmptyForm(title = "Untitled form") {
1049
1220
  submitLabel: "Submit",
1050
1221
  successTitle: "Thanks!",
1051
1222
  successMessage: "Your response has been recorded.",
1052
- showProgress: true,
1053
- submissionLimit: "multiple"
1223
+ showProgress: true
1054
1224
  }
1055
1225
  };
1056
1226
  }
@@ -1112,12 +1282,21 @@ function assembleBlock(spec) {
1112
1282
  break;
1113
1283
  case "rating":
1114
1284
  block.max = clampInt(spec.max, 3, 10, 5);
1285
+ if (spec.insightsMetric === "csat" && block.max === 5) {
1286
+ block.insightsMetric = "csat";
1287
+ }
1115
1288
  break;
1116
1289
  case "linear_scale":
1117
1290
  block.min = spec.min === 0 ? 0 : 1;
1118
1291
  block.max = clampInt(spec.max, 2, 10, 10);
1119
1292
  if (spec.minLabel) block.minLabel = str2(spec.minLabel, 100);
1120
1293
  if (spec.maxLabel) block.maxLabel = str2(spec.maxLabel, 100);
1294
+ delete block.insightsMetric;
1295
+ if (spec.insightsMetric === "nps" && block.min === 0 && block.max === 10) {
1296
+ block.insightsMetric = "nps";
1297
+ } else if (spec.insightsMetric === "csat" && block.min === 1 && block.max === 5) {
1298
+ block.insightsMetric = "csat";
1299
+ }
1121
1300
  break;
1122
1301
  case "number":
1123
1302
  if (typeof spec.min === "number") block.min = spec.min;
@@ -1255,25 +1434,30 @@ var sha1Base64 = (bytes) => bytesToBase64(new Sha1().update(bytes).digest());
1255
1434
 
1256
1435
  // src/client.ts
1257
1436
  var FilloError = class extends Error {
1258
- constructor(message, status, retryAfterSec2) {
1437
+ constructor(message, status, retryAfterSec2, code) {
1259
1438
  super(message);
1260
1439
  this.status = status;
1261
1440
  this.retryAfterSec = retryAfterSec2;
1441
+ this.code = code;
1262
1442
  this.name = "FilloError";
1263
1443
  }
1264
1444
  status;
1265
1445
  retryAfterSec;
1446
+ code;
1266
1447
  };
1267
1448
  function isFilloError(err) {
1268
1449
  return err instanceof Error && err.name === "FilloError";
1269
1450
  }
1270
- async function errorMessage(res, fallback) {
1451
+ async function errorDetails(res, fallback) {
1271
1452
  try {
1272
1453
  const body = await res.json();
1273
- if (typeof body.error === "string" && body.error) return body.error;
1454
+ return {
1455
+ message: typeof body.error === "string" && body.error ? body.error : fallback,
1456
+ code: typeof body.code === "string" && body.code ? body.code : void 0
1457
+ };
1274
1458
  } catch {
1275
1459
  }
1276
- return fallback;
1460
+ return { message: fallback };
1277
1461
  }
1278
1462
  function retryAfterSec(res) {
1279
1463
  const raw = Number(res.headers.get("retry-after"));
@@ -1281,11 +1465,31 @@ function retryAfterSec(res) {
1281
1465
  }
1282
1466
  var MAX_CHUNK_RETRIES = 3;
1283
1467
  var DEFAULT_TIMEOUT_MS = 3e4;
1468
+ var S3_PART_TIMEOUT_MS = 5 * 6e4;
1469
+ var UPLOAD_CONTROL_TIMEOUT_MS = 10 * 6e4;
1470
+ function waitForRetry(ms, signal) {
1471
+ signal?.throwIfAborted();
1472
+ return new Promise((resolve, reject) => {
1473
+ const aborted = () => {
1474
+ clearTimeout(timer);
1475
+ reject(signal?.reason);
1476
+ };
1477
+ const timer = setTimeout(() => {
1478
+ signal?.removeEventListener("abort", aborted);
1479
+ resolve();
1480
+ }, ms);
1481
+ signal?.addEventListener("abort", aborted, { once: true });
1482
+ });
1483
+ }
1484
+ function isControlRetryable(error, includeBusy = false) {
1485
+ const status = isFilloError(error) ? error.status ?? 0 : 0;
1486
+ return status === 0 || status === 503 || status === 504 || includeBusy && status === 409 && isFilloError(error) && error.retryAfterSec !== void 0;
1487
+ }
1284
1488
  function isRetryableStatus(status) {
1285
1489
  return status >= 500 || status === 429 || status === 408;
1286
1490
  }
1287
1491
  function asRequestError(err) {
1288
- if (err instanceof DOMException && err.name === "TimeoutError")
1492
+ if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "TimeoutError")
1289
1493
  return new FilloError("Request timed out", 0);
1290
1494
  return err;
1291
1495
  }
@@ -1356,8 +1560,8 @@ var FilloClient = class {
1356
1560
  throw asRequestError(err);
1357
1561
  }
1358
1562
  if (!res.ok) {
1359
- const message = await errorMessage(res, `Request failed: ${res.status}`);
1360
- throw new FilloError(message, res.status, retryAfterSec(res));
1563
+ const { message, code } = await errorDetails(res, `Request failed: ${res.status}`);
1564
+ throw new FilloError(message, res.status, retryAfterSec(res), code);
1361
1565
  }
1362
1566
  return await res.json();
1363
1567
  }
@@ -1368,8 +1572,10 @@ var FilloClient = class {
1368
1572
  return form;
1369
1573
  }
1370
1574
  /**
1371
- * Upsert a code-defined form into the workspace identified by the client's
1372
- * publishable key. Returns the canonical form id used for submissions.
1575
+ * Resolve a code-defined form through the workspace identified by the
1576
+ * client's publishable key. Depending on workspace policy, changed content
1577
+ * may be staged for review or resolved to the authoritative live snapshot.
1578
+ * Returns the canonical form id used for submissions.
1373
1579
  */
1374
1580
  syncForm(handle, schema, theme) {
1375
1581
  if (!this.key) {
@@ -1409,11 +1615,34 @@ var FilloClient = class {
1409
1615
  return { ok: false, errors: body2.errors };
1410
1616
  }
1411
1617
  if (!res.ok) {
1412
- const message = await errorMessage(res, `Submit failed: ${res.status}`);
1413
- throw new FilloError(message, res.status, retryAfterSec(res));
1618
+ const { message, code } = await errorDetails(res, `Submit failed: ${res.status}`);
1619
+ throw new FilloError(message, res.status, retryAfterSec(res), code);
1414
1620
  }
1415
1621
  const body = await res.json();
1416
- return { ok: true, responseId: body.id, duplicate: body.duplicate };
1622
+ return { ok: true, responseId: body.id, duplicate: body.duplicate, updated: body.updated };
1623
+ }
1624
+ /**
1625
+ * The identified person's own living response on an upsert-mode form —
1626
+ * used to prefill their answers for editing. Requires a VERIFIED identity
1627
+ * ({id, hash}); anything else 404s (returned as null), because an
1628
+ * unverified read would hand anyone's answers to any page script.
1629
+ *
1630
+ * `scopeValue` scopes the lookup for forms whose response limit is keyed to
1631
+ * a field (responseLimit.scopeField) — e.g. one response per article. When
1632
+ * the form is scoped, the server returns 404 unless the matching scope value
1633
+ * is sent, so a page loaded for article B never prefills article A's answers.
1634
+ * Optional and back-compatible: omit it for unscoped forms.
1635
+ */
1636
+ async fetchOwnResponse(formId, respondent, scopeValue) {
1637
+ try {
1638
+ return await this.json(
1639
+ `/forms/${encodeURIComponent(formId)}/respondent-response`,
1640
+ { method: "POST", body: JSON.stringify({ respondent, scopeValue }) }
1641
+ );
1642
+ } catch (err) {
1643
+ if (isFilloError(err) && (err.status === 404 || err.status === 403)) return null;
1644
+ throw err;
1645
+ }
1417
1646
  }
1418
1647
  /**
1419
1648
  * Open a respondent session for funnel analysis. Fire-and-forget: returns the
@@ -1448,19 +1677,60 @@ var FilloClient = class {
1448
1677
  } catch {
1449
1678
  }
1450
1679
  }
1680
+ /**
1681
+ * Create a saved-progress draft (forms with settings.saveProgress). Returns
1682
+ * the draft id and its ownership token — the token is shown only once.
1683
+ */
1684
+ createDraft(formId, body) {
1685
+ return this.json(`/forms/${encodeURIComponent(formId)}/drafts`, {
1686
+ method: "POST",
1687
+ body: JSON.stringify(body)
1688
+ });
1689
+ }
1690
+ /** Fetch a draft to restore. 404s once expired, consumed, or deleted. When
1691
+ * `adopt` is set (resume-link adoption), the server rotates the bearer and
1692
+ * returns the new `token` — the URL token becomes single-use. */
1693
+ getDraft(draftId, token, adopt) {
1694
+ return this.json(`/drafts/${encodeURIComponent(draftId)}`, {
1695
+ headers: adopt ? { "X-Fillo-Draft-Token": token, "X-Fillo-Draft-Adopt": "1" } : { "X-Fillo-Draft-Token": token }
1696
+ });
1697
+ }
1698
+ /**
1699
+ * Overwrite a draft's answers/page and slide its expiry. `keepalive` lets a
1700
+ * tab-close flush outlive the page; browsers reject keepalive bodies over
1701
+ * ~64KB, in which case this save is simply lost — the debounced autosaves
1702
+ * every few keystrokes are the real persistence, the flush is a bonus.
1703
+ */
1704
+ async saveDraft(draftId, token, body, opts) {
1705
+ await this.json(`/drafts/${encodeURIComponent(draftId)}`, {
1706
+ method: "PATCH",
1707
+ headers: { "X-Fillo-Draft-Token": token },
1708
+ body: JSON.stringify(body),
1709
+ ...opts?.keepalive ? { keepalive: true } : {}
1710
+ });
1711
+ }
1712
+ /** Discard a draft (the "Start over" path). */
1713
+ async deleteDraft(draftId, token) {
1714
+ await this.json(`/drafts/${encodeURIComponent(draftId)}`, {
1715
+ method: "DELETE",
1716
+ headers: { "X-Fillo-Draft-Token": token }
1717
+ });
1718
+ }
1451
1719
  /** Current state of an upload session — used to resume after interruption. */
1452
- getUploadSession(sessionId, token) {
1720
+ getUploadSession(sessionId, token, signal = AbortSignal.timeout(UPLOAD_CONTROL_TIMEOUT_MS)) {
1453
1721
  return this.json(
1454
1722
  `/uploads/${encodeURIComponent(sessionId)}`,
1455
- token ? { headers: { "X-Fillo-Upload-Token": token } } : void 0
1723
+ {
1724
+ ...token ? { headers: { "X-Fillo-Upload-Token": token } } : {},
1725
+ signal
1726
+ }
1456
1727
  );
1457
1728
  }
1458
1729
  /**
1459
- * Resumable chunked upload. Creates (or resumes) a session, streams the file
1460
- * chunk by chunk with progress callbacks, and finalizes into a FileValue that
1461
- * goes into the response data. Survives flaky connections: each chunk is
1462
- * retried, and on repeated failure the true offset is re-queried so no byte
1463
- * is sent twice or skipped.
1730
+ * Provider-aware browser-direct upload. Creates a session, uses the storage
1731
+ * transport selected by the server, reports progress, and finalizes into a
1732
+ * FileValue. Resumable providers can continue an existing session; one-shot
1733
+ * transports are retried according to their own safe semantics.
1464
1734
  *
1465
1735
  * Depending on the form's storage settings the server picks a transport:
1466
1736
  * direct-to-Google-Drive resumable
@@ -1468,28 +1738,68 @@ var FilloClient = class {
1468
1738
  * form owner's Drive and never touch the Fillo server.
1469
1739
  */
1470
1740
  async uploadFile(formId, file, options2) {
1471
- const name = file instanceof File ? file.name : "upload.bin";
1472
- const mime = file.type || "application/octet-stream";
1741
+ const name = typeof File !== "undefined" && file instanceof File ? file.name : "upload.bin";
1742
+ const mime = (file.type || "application/octet-stream").trim().toLowerCase();
1473
1743
  let session;
1474
1744
  if (options2.sessionId) {
1475
- session = await this.getUploadSession(options2.sessionId, options2.uploadToken);
1745
+ const resumed = await this.getUploadSession(
1746
+ options2.sessionId,
1747
+ options2.uploadToken,
1748
+ this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
1749
+ );
1750
+ if (resumed.formId !== formId || resumed.fieldId !== options2.fieldId || resumed.fileName !== name || resumed.size !== file.size || resumed.mime.trim().toLowerCase() !== mime) {
1751
+ throw new FilloError("File does not match this upload session", 400);
1752
+ }
1753
+ session = resumed;
1754
+ session.token ||= options2.uploadToken;
1476
1755
  } else {
1477
- session = await this.json(
1756
+ const requestId = createId(24);
1757
+ const requestToken = createId(32);
1758
+ const createSession = () => this.json(
1478
1759
  `/forms/${encodeURIComponent(formId)}/uploads`,
1479
1760
  {
1480
1761
  method: "POST",
1481
1762
  body: JSON.stringify({
1763
+ requestId,
1764
+ uploadToken: requestToken,
1482
1765
  fieldId: options2.fieldId,
1483
1766
  fileName: name,
1484
1767
  size: file.size,
1485
1768
  mime
1486
- })
1769
+ }),
1770
+ signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
1487
1771
  }
1488
1772
  );
1773
+ const deadline = Date.now() + UPLOAD_CONTROL_TIMEOUT_MS;
1774
+ let backoffMs = 250;
1775
+ for (; ; ) {
1776
+ try {
1777
+ session = await createSession();
1778
+ break;
1779
+ } catch (error) {
1780
+ if (options2.signal?.aborted) throw error;
1781
+ if (!isControlRetryable(error, true)) throw error;
1782
+ const remaining = deadline - Date.now();
1783
+ if (remaining <= 0) throw error;
1784
+ await waitForRetry(
1785
+ Math.min(backoffMs, remaining),
1786
+ options2.signal
1787
+ );
1788
+ backoffMs = Math.min(backoffMs * 2, 5e3);
1789
+ }
1790
+ }
1489
1791
  }
1792
+ options2.onSession?.({
1793
+ sessionId: session.id,
1794
+ uploadToken: session.token
1795
+ });
1796
+ if (session.status === "complete" && session.file) return session.file;
1797
+ if (session.status === "aborted") throw new FilloError("This upload session was cancelled", 409);
1490
1798
  let completeBody;
1491
1799
  if (session.transport?.type === "gdrive") {
1492
1800
  await this.driveUploadLoop(session, session.transport.uploadUrl, file, options2);
1801
+ } else if (session.transport?.type === "s3-multipart") {
1802
+ await this.s3MultipartUploadLoop(session, file, options2);
1493
1803
  } else if (session.transport?.type === "s3-put") {
1494
1804
  await this.s3PutUpload(session.transport.uploadUrl, file, options2);
1495
1805
  } else if (session.transport?.type === "box") {
@@ -1497,14 +1807,52 @@ var FilloClient = class {
1497
1807
  } else {
1498
1808
  throw new FilloError("This form's storage doesn't support uploads \u2014 connect Drive, S3, or Box.");
1499
1809
  }
1500
- const done = await this.json(
1810
+ const finalize = () => this.json(
1501
1811
  `/uploads/${encodeURIComponent(session.id)}/complete`,
1502
1812
  {
1503
1813
  method: "POST",
1504
1814
  headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
1505
- ...completeBody ? { body: JSON.stringify(completeBody) } : {}
1815
+ ...completeBody ? { body: JSON.stringify(completeBody) } : {},
1816
+ signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
1506
1817
  }
1507
1818
  );
1819
+ let done;
1820
+ try {
1821
+ done = await finalize();
1822
+ } catch (error) {
1823
+ if (options2.signal?.aborted) throw error;
1824
+ if (!isControlRetryable(error)) throw error;
1825
+ const deadline = Date.now() + UPLOAD_CONTROL_TIMEOUT_MS;
1826
+ let backoffMs = 250;
1827
+ for (; ; ) {
1828
+ const reconciled = await this.getUploadSession(
1829
+ session.id,
1830
+ session.token,
1831
+ this.uploadSignal(
1832
+ options2,
1833
+ Math.max(1, deadline - Date.now())
1834
+ )
1835
+ );
1836
+ if (reconciled.status === "complete" && reconciled.file) {
1837
+ return reconciled.file;
1838
+ }
1839
+ if (reconciled.status === "aborted") throw error;
1840
+ try {
1841
+ done = await finalize();
1842
+ break;
1843
+ } catch (retryError) {
1844
+ if (options2.signal?.aborted) throw retryError;
1845
+ if (!isControlRetryable(retryError, true)) throw retryError;
1846
+ }
1847
+ const remaining = deadline - Date.now();
1848
+ if (remaining <= 0) throw error;
1849
+ await waitForRetry(
1850
+ Math.min(backoffMs, remaining),
1851
+ options2.signal
1852
+ );
1853
+ backoffMs = Math.min(backoffMs * 2, 5e3);
1854
+ }
1855
+ }
1508
1856
  if (!done.file) throw new FilloError("Upload completed but no file was returned");
1509
1857
  return done.file;
1510
1858
  }
@@ -1613,10 +1961,10 @@ var FilloClient = class {
1613
1961
  /**
1614
1962
  * Caller signal (if any) combined with a fresh per-request timeout. Created per
1615
1963
  * fetch so each retry attempt gets its own deadline; the caller's own abort
1616
- * still drives the resumable-upload cancel semantics.
1964
+ * still drives upload cancellation across every provider transport.
1617
1965
  */
1618
- uploadSignal(options2) {
1619
- const timeout = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
1966
+ uploadSignal(options2, timeoutMs = DEFAULT_TIMEOUT_MS) {
1967
+ const timeout = AbortSignal.timeout(timeoutMs);
1620
1968
  return options2.signal ? AbortSignal.any([options2.signal, timeout]) : timeout;
1621
1969
  }
1622
1970
  /** Retry a request with exponential backoff; never retries an aborted upload. */
@@ -1665,7 +2013,13 @@ var FilloClient = class {
1665
2013
  const match = range?.match(/bytes=0-(\d+)/);
1666
2014
  if (match) offset = Number(match[1]) + 1;
1667
2015
  else if (range == null) offset = 0;
1668
- else offset = (await this.getUploadSession(session.id, session.token)).uploadedBytes;
2016
+ else {
2017
+ offset = (await this.getUploadSession(
2018
+ session.id,
2019
+ session.token,
2020
+ this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
2021
+ )).uploadedBytes;
2022
+ }
1669
2023
  break;
1670
2024
  }
1671
2025
  if (res.ok) {
@@ -1678,7 +2032,11 @@ var FilloClient = class {
1678
2032
  attempt += 1;
1679
2033
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
1680
2034
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
1681
- const fresh = await this.getUploadSession(session.id, session.token);
2035
+ const fresh = await this.getUploadSession(
2036
+ session.id,
2037
+ session.token,
2038
+ this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
2039
+ );
1682
2040
  offset = fresh.uploadedBytes;
1683
2041
  if (offset >= file.size) break;
1684
2042
  end = Math.min(offset + session.chunkSize, file.size);
@@ -1695,6 +2053,80 @@ var FilloClient = class {
1695
2053
  }
1696
2054
  }
1697
2055
  }
2056
+ /**
2057
+ * S3-compatible multipart protocol. Fillo owns the provider upload id and is
2058
+ * the only party allowed to complete it; the browser receives only narrowly
2059
+ * scoped UploadPart URLs. That keeps uploads resumable and means an old or
2060
+ * slow browser request cannot materialize an object after server-side abort.
2061
+ */
2062
+ async s3MultipartUploadLoop(session, file, options2) {
2063
+ 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) {
2064
+ throw new FilloError("S3 upload session returned invalid progress", 502);
2065
+ }
2066
+ const report = makeReporter(file.size, options2);
2067
+ let offset = session.uploadedBytes;
2068
+ report(offset);
2069
+ while (offset < file.size) {
2070
+ options2.signal?.throwIfAborted();
2071
+ const partNumber = Math.floor(offset / session.chunkSize) + 1;
2072
+ if (partNumber > 1e4) {
2073
+ throw new FilloError("S3 upload needs too many parts", 413);
2074
+ }
2075
+ const end = Math.min(offset + session.chunkSize, file.size);
2076
+ const chunk = file.slice(offset, end);
2077
+ let attempt = 0;
2078
+ for (; ; ) {
2079
+ let uploadUrl;
2080
+ try {
2081
+ const signed = await this.json(
2082
+ `/uploads/${encodeURIComponent(session.id)}/parts`,
2083
+ {
2084
+ method: "POST",
2085
+ headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
2086
+ body: JSON.stringify({ partNumber }),
2087
+ signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
2088
+ }
2089
+ );
2090
+ if (typeof signed.uploadUrl !== "string" || !signed.uploadUrl) {
2091
+ throw new FilloError("S3 part URL was unavailable", 502);
2092
+ }
2093
+ uploadUrl = signed.uploadUrl;
2094
+ const res = await this.fetch(uploadUrl, {
2095
+ method: "PUT",
2096
+ body: chunk,
2097
+ signal: this.uploadSignal(options2, S3_PART_TIMEOUT_MS)
2098
+ });
2099
+ if (!res.ok) {
2100
+ throw new FilloError(`S3 part upload failed: ${res.status}`, res.status);
2101
+ }
2102
+ offset = end;
2103
+ report(offset);
2104
+ break;
2105
+ } catch (err) {
2106
+ if (options2.signal?.aborted) throw err;
2107
+ const fresh = await this.getUploadSession(
2108
+ session.id,
2109
+ session.token,
2110
+ this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
2111
+ );
2112
+ if (fresh.status === "aborted") {
2113
+ throw new FilloError("This upload session was cancelled", 409);
2114
+ }
2115
+ if (!Number.isSafeInteger(fresh.uploadedBytes) || fresh.uploadedBytes < offset || fresh.uploadedBytes > file.size || fresh.uploadedBytes < file.size && fresh.uploadedBytes % session.chunkSize !== 0) {
2116
+ throw new FilloError("S3 upload session returned invalid progress", 502);
2117
+ }
2118
+ if (fresh.uploadedBytes > offset) {
2119
+ offset = fresh.uploadedBytes;
2120
+ report(offset);
2121
+ break;
2122
+ }
2123
+ attempt += 1;
2124
+ if (attempt >= MAX_CHUNK_RETRIES) throw err;
2125
+ await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
2126
+ }
2127
+ }
2128
+ }
2129
+ }
1698
2130
  /**
1699
2131
  * S3-compatible single PUT to a presigned URL — bytes go straight to the
1700
2132
  * bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
@@ -1785,7 +2217,7 @@ function prefillFromParams(form, params) {
1785
2217
  }
1786
2218
  case "multi_select": {
1787
2219
  const ids = new Set(field2.options.map((o) => o.id));
1788
- const picked = raw.split(",").filter((v) => ids.has(v));
2220
+ const picked = [...new Set(raw.split(",").filter((v) => ids.has(v)))];
1789
2221
  if (picked.length > 0) data[field2.id] = picked;
1790
2222
  break;
1791
2223
  }
@@ -1842,25 +2274,36 @@ function randomSubmissionKey() {
1842
2274
  }
1843
2275
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1844
2276
  }
1845
- function readStoredSubmission(formId) {
2277
+ function readStoredJson(memory, storageKey2, memoryKey, isValid) {
1846
2278
  const store = browserStorage();
1847
- if (!store) return memorySubmissionKeys.get(formId) ?? null;
2279
+ if (!store) return memory.get(memoryKey) ?? null;
1848
2280
  try {
1849
- const parsed = JSON.parse(store.getItem(submissionStorageKey(formId)) ?? "null");
1850
- if (parsed && typeof parsed.key === "string") return parsed;
2281
+ const parsed = JSON.parse(store.getItem(storageKey2) ?? "null");
2282
+ if (parsed && isValid(parsed)) return parsed;
1851
2283
  } catch {
1852
2284
  }
1853
2285
  return null;
1854
2286
  }
1855
- function writeStoredSubmission(formId, value) {
1856
- memorySubmissionKeys.set(formId, value);
2287
+ function writeStoredJson(memory, storageKey2, memoryKey, value) {
2288
+ memory.set(memoryKey, value);
1857
2289
  const store = browserStorage();
1858
2290
  if (!store) return;
1859
2291
  try {
1860
- store.setItem(submissionStorageKey(formId), JSON.stringify(value));
2292
+ store.setItem(storageKey2, JSON.stringify(value));
1861
2293
  } catch {
1862
2294
  }
1863
2295
  }
2296
+ function readStoredSubmission(formId) {
2297
+ return readStoredJson(
2298
+ memorySubmissionKeys,
2299
+ submissionStorageKey(formId),
2300
+ formId,
2301
+ (p) => typeof p.key === "string"
2302
+ );
2303
+ }
2304
+ function writeStoredSubmission(formId, value) {
2305
+ writeStoredJson(memorySubmissionKeys, submissionStorageKey(formId), formId, value);
2306
+ }
1864
2307
  function ensureSubmissionKey(formId) {
1865
2308
  const existing = readStoredSubmission(formId);
1866
2309
  if (existing?.key) return existing.key;
@@ -1876,14 +2319,52 @@ function markSubmitted(formId, responseId) {
1876
2319
  responseId
1877
2320
  });
1878
2321
  }
1879
- function hasSubmittedOnce(form, formId) {
1880
- if (form.settings.submissionLimit !== "once_per_visitor" || !formId) return false;
1881
- return Boolean(readStoredSubmission(formId)?.submittedAt);
2322
+ function visitorSubmissionKeyId(form, formId, data) {
2323
+ const scope = responseScopeValue(form.settings, data);
2324
+ return scope === null ? formId : `${formId}::${scope}`;
2325
+ }
2326
+ function hasSubmittedOnce(form, formId, data) {
2327
+ if (form.settings.responseLimit?.by !== "browser" || !formId) return false;
2328
+ return Boolean(readStoredSubmission(visitorSubmissionKeyId(form, formId, data))?.submittedAt);
2329
+ }
2330
+ var memoryDraftRefs = /* @__PURE__ */ new Map();
2331
+ function draftStorageKey(formId) {
2332
+ return `fillo:draft:${formId}`;
2333
+ }
2334
+ function readDraftRef(formId) {
2335
+ return readStoredJson(memoryDraftRefs, draftStorageKey(formId), formId, (p) => {
2336
+ const ref = p;
2337
+ return typeof ref.id === "string" && typeof ref.token === "string";
2338
+ });
2339
+ }
2340
+ function writeDraftRef(formId, ref) {
2341
+ writeStoredJson(memoryDraftRefs, draftStorageKey(formId), formId, ref);
2342
+ }
2343
+ function clearDraftRef(formId) {
2344
+ memoryDraftRefs.delete(formId);
2345
+ const store = browserStorage();
2346
+ if (!store) return;
2347
+ try {
2348
+ store.removeItem(draftStorageKey(formId));
2349
+ } catch {
2350
+ }
2351
+ }
2352
+ function isDraftGone(err) {
2353
+ return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
1882
2354
  }
2355
+ var DRAFT_DEBOUNCE_MS = 1500;
1883
2356
  function submitFailureMessage(err) {
1884
2357
  if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
1885
2358
  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.";
1886
2359
  }
2360
+ function syncResolutionFailureMessage(err) {
2361
+ if (isFilloError(err)) {
2362
+ const status = err.status ?? 0;
2363
+ const definitive = status > 0 && status < 500 && status !== 408 && status !== 429;
2364
+ if (definitive) return "This form is unavailable.";
2365
+ }
2366
+ return submitFailureMessage(err);
2367
+ }
1887
2368
  function createFormController(options2) {
1888
2369
  const { onChange, onSubmitted } = options2;
1889
2370
  const initial = normalizeFormSchema(options2.form);
@@ -1891,15 +2372,14 @@ function createFormController(options2) {
1891
2372
  let form = initial.schema;
1892
2373
  let formId = options2.formId;
1893
2374
  let client = options2.client;
2375
+ let respondent = options2.respondent;
1894
2376
  let data = { ...options2.initialData ?? {} };
1895
2377
  let errors = {};
1896
2378
  let pageIndex = 0;
1897
2379
  let status = "idle";
2380
+ let restoredSubmission = false;
2381
+ let resumeLinkFailed = false;
1898
2382
  queueMicrotask(() => {
1899
- if (status === "idle" && hasSubmittedOnce(form, formId)) {
1900
- status = "submitted";
1901
- notify();
1902
- }
1903
2383
  if (typeof location !== "undefined" && location.search) {
1904
2384
  const params = Object.fromEntries(new URLSearchParams(location.search));
1905
2385
  let merged = null;
@@ -1914,6 +2394,35 @@ function createFormController(options2) {
1914
2394
  notify();
1915
2395
  }
1916
2396
  }
2397
+ if (status === "idle" && hasSubmittedOnce(form, formId, data)) {
2398
+ status = "submitted";
2399
+ restoredSubmission = true;
2400
+ notify();
2401
+ }
2402
+ let adopting = false;
2403
+ if (form.settings.saveProgress && formId && client && typeof location !== "undefined" && location.hash.includes("fillo-draft=")) {
2404
+ const match = /fillo-draft=([^&]+)/.exec(location.hash);
2405
+ const raw = match?.[1] ? decodeURIComponent(match[1]) : "";
2406
+ const dot = raw.indexOf(".");
2407
+ if (dot > 0) {
2408
+ adopting = true;
2409
+ const did = raw.slice(0, dot);
2410
+ const dtok = raw.slice(dot + 1);
2411
+ client.getDraft(did, dtok, true).then((d) => {
2412
+ writeDraftRef(formId, { id: did, token: d.token ?? dtok });
2413
+ try {
2414
+ history.replaceState(null, "", location.pathname + location.search);
2415
+ } catch {
2416
+ }
2417
+ }).catch((err) => {
2418
+ if (isDraftGone(err)) {
2419
+ resumeLinkFailed = true;
2420
+ notify();
2421
+ }
2422
+ }).finally(() => maybeRestoreDraft());
2423
+ }
2424
+ }
2425
+ if (!adopting) maybeRestoreDraft();
1917
2426
  });
1918
2427
  let submitError;
1919
2428
  const uploadingFields = /* @__PURE__ */ new Set();
@@ -1922,6 +2431,17 @@ function createFormController(options2) {
1922
2431
  const idempotencyKey = randomSubmissionKey();
1923
2432
  let sessionId = null;
1924
2433
  let sessionStarted = false;
2434
+ let resumedDraft = false;
2435
+ let editingPrevious = false;
2436
+ let submissionKind = "created";
2437
+ let editAttempted = false;
2438
+ let draftRef = null;
2439
+ let draftTimer = null;
2440
+ let draftChain = Promise.resolve();
2441
+ let draftDirty = false;
2442
+ let draftDisabled = false;
2443
+ let draftRestoreAttempted = false;
2444
+ let draftAffinityAttempted = false;
1925
2445
  function computeSnapshot() {
1926
2446
  const pageCount = form.pages.length;
1927
2447
  const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
@@ -1933,11 +2453,20 @@ function createFormController(options2) {
1933
2453
  pageIndex: clamped,
1934
2454
  pageCount,
1935
2455
  page,
1936
- blocks: visibleBlocks(page, data),
2456
+ // Whole-form visibility fixpoint (not just this page's fields) so a field
2457
+ // gated by a cross-page answer renders/validates exactly when the server
2458
+ // keeps it — never a field whose answer submit would silently drop.
2459
+ blocks: visiblePageBlocks(form, page, data),
1937
2460
  isFirstPage: clamped === 0,
1938
2461
  isLastPage: clamped === pageCount - 1,
1939
2462
  uploading: uploadingFields.size > 0,
1940
- submitError
2463
+ submitError,
2464
+ restoredSubmission,
2465
+ resumedDraft,
2466
+ editingPrevious,
2467
+ duplicateSubmission: submissionKind === "duplicate",
2468
+ updatedSubmission: submissionKind === "updated",
2469
+ resumeLinkFailed
1941
2470
  };
1942
2471
  }
1943
2472
  let snapshot = computeSnapshot();
@@ -1956,13 +2485,161 @@ function createFormController(options2) {
1956
2485
  const target = form.pages[index];
1957
2486
  if (!target) return {};
1958
2487
  const pageErrors = {};
1959
- for (const block of visibleBlocks(target, data)) {
2488
+ for (const block of visiblePageBlocks(form, target, data)) {
1960
2489
  if (!isField(block)) continue;
1961
2490
  const error = validateField(block, data[block.id]);
1962
2491
  if (error) pageErrors[block.id] = error;
1963
2492
  }
1964
2493
  return pageErrors;
1965
2494
  }
2495
+ function draftsEnabled() {
2496
+ return Boolean(form.settings.saveProgress && client && formId && !draftDisabled);
2497
+ }
2498
+ function knownFieldIds() {
2499
+ const ids = /* @__PURE__ */ new Set();
2500
+ for (const page of form.pages) {
2501
+ for (const block of page.blocks) if (isField(block)) ids.add(block.id);
2502
+ }
2503
+ return ids;
2504
+ }
2505
+ function applyDraftSnapshot(draft) {
2506
+ if (status !== "idle" || sessionStarted) return;
2507
+ const ids = knownFieldIds();
2508
+ let merged = null;
2509
+ for (const [key, value] of Object.entries(draft.data ?? {})) {
2510
+ if (data[key] === void 0 && ids.has(key)) {
2511
+ merged = merged ?? { ...data };
2512
+ merged[key] = value;
2513
+ }
2514
+ }
2515
+ const target = typeof draft.page === "number" && Number.isFinite(draft.page) ? Math.max(0, Math.min(Math.floor(draft.page), form.pages.length - 1)) : 0;
2516
+ const movePage = target > 0 && pageIndex === 0;
2517
+ if (!merged && !movePage) return;
2518
+ if (merged) data = merged;
2519
+ if (movePage) pageIndex = target;
2520
+ resumedDraft = true;
2521
+ notify();
2522
+ }
2523
+ function maybeRestoreDraft() {
2524
+ if (status !== "idle") return;
2525
+ if (!draftsEnabled()) {
2526
+ maybePrefillOwnResponse();
2527
+ return;
2528
+ }
2529
+ const fid = formId;
2530
+ const c = client;
2531
+ const ref = draftRef ?? readDraftRef(fid);
2532
+ if (ref && !draftRestoreAttempted) {
2533
+ draftRestoreAttempted = true;
2534
+ draftRef = ref;
2535
+ void c.getDraft(ref.id, ref.token).then(applyDraftSnapshot).catch((err) => {
2536
+ if (isDraftGone(err)) {
2537
+ clearDraftRef(fid);
2538
+ draftRef = null;
2539
+ }
2540
+ });
2541
+ return;
2542
+ }
2543
+ if (!ref && !draftAffinityAttempted && respondent?.hash) {
2544
+ draftAffinityAttempted = true;
2545
+ void c.createDraft(fid, { data: {}, page: 0, respondent }).then((created) => {
2546
+ if (draftRef) return;
2547
+ draftRef = { id: created.id, token: created.token };
2548
+ writeDraftRef(fid, draftRef);
2549
+ if (created.existing) {
2550
+ return c.getDraft(created.id, created.token).then(applyDraftSnapshot);
2551
+ }
2552
+ maybePrefillOwnResponse();
2553
+ }).catch((err) => {
2554
+ if (!(isFilloError(err) && err.status !== void 0 && err.status >= 400 && err.status < 500)) {
2555
+ draftAffinityAttempted = false;
2556
+ }
2557
+ });
2558
+ }
2559
+ }
2560
+ function maybePrefillOwnResponse() {
2561
+ if (editAttempted || form.settings.responseLimit?.onRepeat !== "update") return;
2562
+ if (!client || !formId || !respondent?.hash || status !== "idle") return;
2563
+ editAttempted = true;
2564
+ const c = client;
2565
+ const scopeValue = responseScopeValue(form.settings, data) ?? void 0;
2566
+ void c.fetchOwnResponse(formId, respondent, scopeValue).then((own) => {
2567
+ if (!own || status !== "idle" || sessionStarted || resumedDraft) return;
2568
+ applyDraftSnapshot({ data: own.data, page: 0 });
2569
+ if (resumedDraft) {
2570
+ resumedDraft = false;
2571
+ editingPrevious = true;
2572
+ notify();
2573
+ }
2574
+ }).catch(() => {
2575
+ });
2576
+ }
2577
+ function persistDraft(keepalive = false) {
2578
+ if (draftTimer) {
2579
+ clearTimeout(draftTimer);
2580
+ draftTimer = null;
2581
+ }
2582
+ if (!draftsEnabled() || status !== "idle" || !draftDirty) return;
2583
+ const fid = formId;
2584
+ const c = client;
2585
+ draftDirty = false;
2586
+ draftChain = draftChain.then(async () => {
2587
+ const payload = {
2588
+ data,
2589
+ page: Math.max(0, Math.min(pageIndex, form.pages.length - 1))
2590
+ };
2591
+ const ref = draftRef ?? readDraftRef(fid);
2592
+ try {
2593
+ if (ref) {
2594
+ try {
2595
+ await c.saveDraft(ref.id, ref.token, payload, keepalive ? { keepalive: true } : void 0);
2596
+ draftRef = ref;
2597
+ return;
2598
+ } catch (err) {
2599
+ if (!isDraftGone(err)) throw err;
2600
+ clearDraftRef(fid);
2601
+ draftRef = null;
2602
+ }
2603
+ }
2604
+ const created = await c.createDraft(
2605
+ fid,
2606
+ respondent?.hash ? { ...payload, respondent } : payload
2607
+ );
2608
+ draftRef = { id: created.id, token: created.token };
2609
+ writeDraftRef(fid, draftRef);
2610
+ if (created.existing) draftDirty = true;
2611
+ } catch (err) {
2612
+ if (isFilloError(err) && err.status !== void 0 && err.status >= 400 && err.status < 500 && err.status !== 429) {
2613
+ draftDisabled = true;
2614
+ } else {
2615
+ draftDirty = true;
2616
+ }
2617
+ }
2618
+ });
2619
+ }
2620
+ function scheduleDraftSave() {
2621
+ if (!draftsEnabled() || status !== "idle") return;
2622
+ draftDirty = true;
2623
+ if (draftTimer) clearTimeout(draftTimer);
2624
+ draftTimer = setTimeout(() => {
2625
+ draftTimer = null;
2626
+ persistDraft();
2627
+ }, DRAFT_DEBOUNCE_MS);
2628
+ }
2629
+ function checkpointDraft() {
2630
+ if (!draftsEnabled() || !sessionStarted && !draftRef) return;
2631
+ draftDirty = true;
2632
+ persistDraft();
2633
+ }
2634
+ function discardDraftState() {
2635
+ if (draftTimer) {
2636
+ clearTimeout(draftTimer);
2637
+ draftTimer = null;
2638
+ }
2639
+ draftDirty = false;
2640
+ if (formId) clearDraftRef(formId);
2641
+ draftRef = null;
2642
+ }
1966
2643
  function setValue(fieldId, value) {
1967
2644
  if (!sessionStarted && client && formId) {
1968
2645
  sessionStarted = true;
@@ -1975,6 +2652,7 @@ function createFormController(options2) {
1975
2652
  errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
1976
2653
  }
1977
2654
  notify();
2655
+ scheduleDraftSave();
1978
2656
  }
1979
2657
  function setUploading(fieldId, busy) {
1980
2658
  if (busy) uploadingFields.add(fieldId);
@@ -1995,11 +2673,13 @@ function createFormController(options2) {
1995
2673
  pageIndex = Math.min(clamped + 1, pageCount - 1);
1996
2674
  if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
1997
2675
  notify();
2676
+ checkpointDraft();
1998
2677
  }
1999
2678
  function back() {
2000
2679
  errors = {};
2001
2680
  pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
2002
2681
  notify();
2682
+ checkpointDraft();
2003
2683
  }
2004
2684
  async function submit() {
2005
2685
  if (status === "submitting" || status === "submitted") return;
@@ -2007,13 +2687,13 @@ function createFormController(options2) {
2007
2687
  if (!result.ok) {
2008
2688
  errors = result.errors;
2009
2689
  const firstBad = form.pages.findIndex(
2010
- (p) => visibleBlocks(p, data).some((b) => b.id in result.errors)
2690
+ (p) => visiblePageBlocks(form, p, data).some((b) => b.id in result.errors)
2011
2691
  );
2012
2692
  if (firstBad >= 0) pageIndex = firstBad;
2013
2693
  notify();
2014
2694
  return;
2015
2695
  }
2016
- if (client && !formId && options2.resolveFormId) {
2696
+ if (client && options2.resolveFormId) {
2017
2697
  status = "submitting";
2018
2698
  submitError = void 0;
2019
2699
  notify();
@@ -2021,14 +2701,15 @@ function createFormController(options2) {
2021
2701
  formId = await options2.resolveFormId();
2022
2702
  } catch (err) {
2023
2703
  status = "idle";
2024
- submitError = submitFailureMessage(err);
2704
+ submitError = syncResolutionFailureMessage(err);
2025
2705
  notify();
2026
2706
  throw err;
2027
2707
  }
2028
2708
  }
2029
2709
  if (client && formId) {
2030
- const visitorKey = form.settings.submissionLimit === "once_per_visitor" ? ensureSubmissionKey(formId) : void 0;
2710
+ const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
2031
2711
  const submissionKey = visitorKey ?? idempotencyKey;
2712
+ const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
2032
2713
  status = "submitting";
2033
2714
  submitError = void 0;
2034
2715
  notify();
@@ -2038,7 +2719,9 @@ function createFormController(options2) {
2038
2719
  hp: options2.getHoneypot?.() ?? "",
2039
2720
  elapsedMs: Date.now() - mountedAt,
2040
2721
  surface: options2.surface ?? "headless",
2041
- submissionKey
2722
+ submissionKey,
2723
+ draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
2724
+ respondent
2042
2725
  });
2043
2726
  } catch (err) {
2044
2727
  status = "idle";
@@ -2048,12 +2731,19 @@ function createFormController(options2) {
2048
2731
  }
2049
2732
  if (!res.ok) {
2050
2733
  errors = res.errors ?? {};
2734
+ const firstBad = form.pages.findIndex(
2735
+ (p) => visiblePageBlocks(form, p, data).some((b) => b.id in errors)
2736
+ );
2737
+ if (firstBad >= 0) pageIndex = firstBad;
2738
+ else submitError = "We couldn't submit \u2014 please review your answers.";
2051
2739
  status = "idle";
2052
2740
  notify();
2053
2741
  return;
2054
2742
  }
2743
+ submissionKind = res.duplicate ? "duplicate" : res.updated ? "updated" : "created";
2055
2744
  if (sessionId) client.reportProgress(sessionId, { completed: true });
2056
- if (visitorKey) markSubmitted(formId, res.responseId);
2745
+ if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
2746
+ discardDraftState();
2057
2747
  status = "submitted";
2058
2748
  notify();
2059
2749
  emitSubmitted(res.responseId, result.data);
@@ -2086,15 +2776,47 @@ function createFormController(options2) {
2086
2776
  setContext(ctx) {
2087
2777
  if ("formId" in ctx) formId = ctx.formId;
2088
2778
  if ("client" in ctx) client = ctx.client;
2779
+ if ("respondent" in ctx) respondent = ctx.respondent;
2089
2780
  if (ctx.form && ctx.form !== form) {
2090
2781
  const next2 = normalizeFormSchema(ctx.form);
2091
2782
  if (!next2.ok) throw new Error(`Invalid form schema: ${next2.error}`);
2092
2783
  form = next2.schema;
2093
2784
  }
2094
- if (status === "idle" && hasSubmittedOnce(form, formId)) status = "submitted";
2785
+ if (status === "idle" && hasSubmittedOnce(form, formId, data)) {
2786
+ status = "submitted";
2787
+ restoredSubmission = true;
2788
+ }
2789
+ maybeRestoreDraft();
2790
+ notify();
2791
+ },
2792
+ flushDraft() {
2793
+ persistDraft(true);
2794
+ },
2795
+ resetDraft() {
2796
+ if (status === "submitting") return;
2797
+ const ref = draftRef ?? (formId ? readDraftRef(formId) : null);
2798
+ discardDraftState();
2799
+ if (ref && client) void client.deleteDraft(ref.id, ref.token).catch(() => {
2800
+ });
2801
+ data = { ...options2.initialData ?? {} };
2802
+ if (typeof location !== "undefined" && location.search) {
2803
+ const params = Object.fromEntries(new URLSearchParams(location.search));
2804
+ for (const [key, value] of Object.entries(prefillFromParams(form, params))) {
2805
+ if (data[key] === void 0) data[key] = value;
2806
+ }
2807
+ }
2808
+ errors = {};
2809
+ pageIndex = 0;
2810
+ submitError = void 0;
2811
+ resumedDraft = false;
2812
+ editingPrevious = false;
2095
2813
  notify();
2096
2814
  },
2097
2815
  destroy() {
2816
+ if (draftTimer) {
2817
+ clearTimeout(draftTimer);
2818
+ draftTimer = null;
2819
+ }
2098
2820
  listeners.clear();
2099
2821
  }
2100
2822
  };
@@ -2105,9 +2827,8 @@ function isAutoSubmitBlock(block) {
2105
2827
  return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
2106
2828
  }
2107
2829
  function needsExplicitSubmit(visible) {
2108
- const interactive = visible.filter((b) => !(isField(b) && b.kind === "hidden"));
2109
- if (interactive.length === 0) return false;
2110
- if (interactive.length > 1) return true;
2830
+ const interactive = visible.filter((b) => isField(b) && b.kind !== "hidden");
2831
+ if (interactive.length !== 1) return true;
2111
2832
  return !isAutoSubmitBlock(interactive[0]);
2112
2833
  }
2113
2834
  function shouldAutoSubmit(field2, value, ctx) {
@@ -2155,7 +2876,8 @@ var FILLO_SLOTS = [
2155
2876
  "error",
2156
2877
  "footer",
2157
2878
  "button",
2158
- "success"
2879
+ "success",
2880
+ "resume"
2159
2881
  ];
2160
2882
  var FILLO_DATA_ATTRS = {
2161
2883
  /** Slot name, on every slot element: `data-fillo="control"`. */
@@ -2204,32 +2926,6 @@ var FILLO_THEME_VARS = [
2204
2926
  { var: "--fillo-primary-contrast" }
2205
2927
  ];
2206
2928
 
2207
- // src/strings.ts
2208
- var DEFAULT_STRINGS = {
2209
- back: "Back",
2210
- next: "Next",
2211
- submit: "Submit",
2212
- submitting: "Submitting\u2026",
2213
- uploading: "Uploading\u2026",
2214
- optional: " (optional)",
2215
- other: "Other",
2216
- otherPrompt: "Other \u2014 please specify",
2217
- otherPlaceholder: "Your answer",
2218
- choosePlaceholder: "Choose\u2026",
2219
- successTitle: "Thanks!",
2220
- successMessage: "Your response has been recorded.",
2221
- closed: "This form is no longer accepting responses.",
2222
- notLive: "This form isn't live yet.",
2223
- submitFailed: "This form can't submit right now. Please try again in a moment.",
2224
- loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
2225
- loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
2226
- loadFailed: "This form could not be loaded.",
2227
- renderFailed: "This form could not be rendered."
2228
- };
2229
- function resolveStrings(overrides) {
2230
- return overrides ? { ...DEFAULT_STRINGS, ...overrides } : DEFAULT_STRINGS;
2231
- }
2232
-
2233
2929
  // src/define.ts
2234
2930
  function defineForm(def) {
2235
2931
  return {
@@ -2250,15 +2946,18 @@ function isCodeForm(form) {
2250
2946
  }
2251
2947
  var syncCache = /* @__PURE__ */ new Map();
2252
2948
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2949
+ function isRetryableSyncError(error) {
2950
+ if (!isFilloError(error)) return true;
2951
+ const status = error.status ?? 0;
2952
+ return status === 0 || status === 408 || status === 429 || status >= 500;
2953
+ }
2253
2954
  async function syncWithRetry(client, form) {
2254
2955
  let delay = 800;
2255
2956
  for (let attempt = 0; ; attempt++) {
2256
2957
  try {
2257
2958
  return await client.syncForm(form.id, form.schema, form.theme ?? null);
2258
2959
  } catch (err) {
2259
- const status = isFilloError(err) ? err.status ?? 0 : 0;
2260
- const retryable = !isFilloError(err) || status === 0 || status === 408 || status === 429 || status >= 500;
2261
- if (!retryable || attempt >= 2) throw err;
2960
+ if (!isRetryableSyncError(err) || attempt >= 2) throw err;
2262
2961
  const hinted = isFilloError(err) ? err.retryAfterSec : void 0;
2263
2962
  await sleep(Math.min(hinted ? hinted * 1e3 : delay * (1 + Math.random() * 0.5), 15e3));
2264
2963
  delay *= 2;
@@ -2270,17 +2969,32 @@ function contentHash(input) {
2270
2969
  for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) | 0;
2271
2970
  return (h >>> 0).toString(36);
2272
2971
  }
2972
+ function formSchemasEqual(left, right) {
2973
+ const canonicalize = (value) => {
2974
+ if (Array.isArray(value)) return value.map(canonicalize);
2975
+ if (value && typeof value === "object") {
2976
+ const record2 = value;
2977
+ return Object.fromEntries(
2978
+ Object.keys(record2).sort().map((key) => [key, canonicalize(record2[key])])
2979
+ );
2980
+ }
2981
+ return value;
2982
+ };
2983
+ return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
2984
+ }
2985
+ var syncTtlMs = (status) => status === "published" ? 36e5 : 6e4;
2986
+ var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError);
2273
2987
  function storageKey(client, handle) {
2274
- return `fillo:sync:${client.baseUrl}|${client.key}|${handle}`;
2988
+ return `fillo:sync:v2:${client.baseUrl}|${client.key}|${handle}`;
2275
2989
  }
2276
2990
  function readStoredSync(client, handle, hash) {
2277
2991
  try {
2278
2992
  const raw = globalThis.localStorage?.getItem(storageKey(client, handle));
2279
2993
  if (!raw) return null;
2280
2994
  const parsed = JSON.parse(raw);
2281
- if (typeof parsed.formId !== "string" || parsed.hash !== hash || typeof parsed.ts !== "number")
2995
+ 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")
2282
2996
  return null;
2283
- const ttl = parsed.status === "published" ? 36e5 : 6e4;
2997
+ const ttl = syncTtlMs(parsed.status);
2284
2998
  if (Date.now() - parsed.ts > ttl) return null;
2285
2999
  return parsed;
2286
3000
  } catch {
@@ -2288,6 +3002,7 @@ function readStoredSync(client, handle, hash) {
2288
3002
  }
2289
3003
  }
2290
3004
  function writeStoredSync(client, handle, hash, r) {
3005
+ if (r.staged || r.resolvedSchema || r.syncError) return;
2291
3006
  try {
2292
3007
  globalThis.localStorage?.setItem(
2293
3008
  storageKey(client, handle),
@@ -2306,23 +3021,44 @@ function syncCodeForm(client, form, opts) {
2306
3021
  const content2 = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
2307
3022
  const hash = contentHash(content2);
2308
3023
  const key = `${client.baseUrl}:${client.key}:${form.id}:${hash}`;
2309
- let pending = syncCache.get(key);
2310
- if (pending && !opts?.bypassCache) return pending;
3024
+ const cached = syncCache.get(key);
3025
+ if (cached && !opts?.bypassCache) {
3026
+ if (cached.expiresAt === null || cached.expiresAt > Date.now()) return cached.promise;
3027
+ syncCache.delete(key);
3028
+ }
2311
3029
  if (!opts?.bypassCache) {
2312
3030
  const stored = readStoredSync(client, form.id, hash);
2313
3031
  if (stored) {
2314
- pending = Promise.resolve({ formId: stored.formId, slug: stored.slug, status: stored.status });
2315
- syncCache.set(key, pending);
2316
- return pending;
3032
+ const promise2 = Promise.resolve({
3033
+ formId: stored.formId,
3034
+ slug: stored.slug,
3035
+ status: stored.status
3036
+ });
3037
+ syncCache.set(key, {
3038
+ promise: promise2,
3039
+ expiresAt: stored.ts + syncTtlMs(stored.status)
3040
+ });
3041
+ return promise2;
2317
3042
  }
2318
3043
  }
2319
- pending = syncWithRetry(client, form).then((r) => {
2320
- writeStoredSync(client, form.id, hash, r);
2321
- return r;
2322
- });
2323
- syncCache.set(key, pending);
2324
- pending.catch(() => syncCache.delete(key));
2325
- return pending;
3044
+ const promise = syncWithRetry(client, form);
3045
+ const entry = { promise, expiresAt: null };
3046
+ syncCache.set(key, entry);
3047
+ promise.then(
3048
+ (result) => {
3049
+ if (syncCache.get(key) !== entry) return;
3050
+ if (isVolatileSync(result)) {
3051
+ syncCache.delete(key);
3052
+ return;
3053
+ }
3054
+ entry.expiresAt = Date.now() + syncTtlMs(result.status);
3055
+ writeStoredSync(client, form.id, hash, result);
3056
+ },
3057
+ () => {
3058
+ if (syncCache.get(key) === entry) syncCache.delete(key);
3059
+ }
3060
+ );
3061
+ return promise;
2326
3062
  }
2327
3063
 
2328
3064
  // src/jsx.ts
@@ -2362,8 +3098,8 @@ var JSX_BLOCK_SPECS = [
2362
3098
  field("MultiSelect", "multi_select", CHOICE_EXTRA, { optionChildren: true }),
2363
3099
  field("Dropdown", "dropdown", CHOICE_EXTRA, { optionChildren: true }),
2364
3100
  field("Checkbox", "checkbox", ["appearance"]),
2365
- field("Rating", "rating", ["max"]),
2366
- field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel"]),
3101
+ field("Rating", "rating", ["max", "insightsMetric"]),
3102
+ field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel", "insightsMetric"]),
2367
3103
  field("Ranking", "ranking", ["options"], { optionChildren: true }),
2368
3104
  field("Matrix", "matrix", ["rows", "columns"]),
2369
3105
  field("Signature", "signature", []),
@@ -2528,6 +3264,18 @@ function buildOption(el, parent, parentId) {
2528
3264
  if (props.icon !== void 0) option.icon = props.icon;
2529
3265
  return option;
2530
3266
  }
3267
+ function assertUniqueOptionIds(blockId, options2, label) {
3268
+ const optionIds = /* @__PURE__ */ new Set();
3269
+ for (const option of options2) {
3270
+ if (optionIds.has(option.id)) {
3271
+ throw new FilloJsxError(
3272
+ "DUPLICATE_ID",
3273
+ `Two ${label} in "${blockId}" share the id "${option.id}" \u2014 option ids are stored answer values and must be unique within the field`
3274
+ );
3275
+ }
3276
+ optionIds.add(option.id);
3277
+ }
3278
+ }
2531
3279
  function schemaFromJsx(children, meta) {
2532
3280
  const elements = flatten(children);
2533
3281
  const pageSpecs = elements.map((el) => ({ el, spec: classify(el) }));
@@ -2568,7 +3316,15 @@ function schemaFromJsx(children, meta) {
2568
3316
  pages.push({ id: "main", blocks });
2569
3317
  }
2570
3318
  const seen = /* @__PURE__ */ new Set();
3319
+ const pageIds = /* @__PURE__ */ new Set();
2571
3320
  for (const page of pages) {
3321
+ if (pageIds.has(page.id)) {
3322
+ throw new FilloJsxError(
3323
+ "DUPLICATE_ID",
3324
+ `Two pages share the id "${page.id}" \u2014 page ids must be unique within a form`
3325
+ );
3326
+ }
3327
+ pageIds.add(page.id);
2572
3328
  for (const block of page.blocks) {
2573
3329
  if (seen.has(block.id)) {
2574
3330
  throw new FilloJsxError(
@@ -2577,6 +3333,11 @@ function schemaFromJsx(children, meta) {
2577
3333
  );
2578
3334
  }
2579
3335
  seen.add(block.id);
3336
+ if ("options" in block) assertUniqueOptionIds(block.id, block.options, "options");
3337
+ if (block.kind === "matrix") {
3338
+ assertUniqueOptionIds(block.id, block.rows, "matrix rows");
3339
+ assertUniqueOptionIds(block.id, block.columns, "matrix columns");
3340
+ }
2580
3341
  }
2581
3342
  }
2582
3343
  return {
@@ -2651,9 +3412,11 @@ function pipeBlock(block, data, form) {
2651
3412
  export {
2652
3413
  BLOCK_KIND_META,
2653
3414
  CONTENT_KINDS,
3415
+ DEFAULT_FIELD_STRINGS,
2654
3416
  DEFAULT_STRINGS,
2655
3417
  DRAFT_KINDS,
2656
3418
  FILLO_DATA_ATTRS,
3419
+ FILLO_MIN_SDK_VERSION,
2657
3420
  FILLO_SCHEMA_VERSION,
2658
3421
  FILLO_SDK_VERSION,
2659
3422
  FILLO_SLOTS,
@@ -2664,6 +3427,8 @@ export {
2664
3427
  JSX_BLOCK_COMPONENTS,
2665
3428
  JSX_BLOCK_SPECS,
2666
3429
  PHONE_COUNTRIES,
3430
+ PHONE_POPOVER_VIEWPORT_GAP,
3431
+ REQUIRED_FIELD_MESSAGE,
2667
3432
  Sha1,
2668
3433
  allFields,
2669
3434
  assembleForm,
@@ -2680,6 +3445,7 @@ export {
2680
3445
  defineForm,
2681
3446
  digitsOnly,
2682
3447
  flagEmoji,
3448
+ formSchemasEqual,
2683
3449
  formatAnswer,
2684
3450
  formatNational,
2685
3451
  isAutoSubmitBlock,
@@ -2691,13 +3457,16 @@ export {
2691
3457
  needsExplicitSubmit,
2692
3458
  normalizeFormSchema,
2693
3459
  normalizeFormTheme,
3460
+ normalizeSettings,
2694
3461
  parsePhone,
2695
3462
  pipeBlock,
3463
+ positionPhonePopover,
2696
3464
  prefillFromParams,
2697
3465
  provisionWorkspace,
2698
3466
  resolveSlotClass,
2699
3467
  resolveStrings,
2700
3468
  resolveText,
3469
+ responseScopeValue,
2701
3470
  schemaFromJsx,
2702
3471
  sha1Base64,
2703
3472
  shouldAutoSubmit,
@@ -2709,5 +3478,6 @@ export {
2709
3478
  validateResponse,
2710
3479
  visibleBlocks,
2711
3480
  visibleFields,
3481
+ visiblePageBlocks,
2712
3482
  when
2713
3483
  };