@usefillo/core 0.7.0 → 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,6 +82,15 @@ 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
  }
@@ -81,7 +98,10 @@ function responseScopeValue(settings, data) {
81
98
  const field2 = settings.responseLimit?.scopeField;
82
99
  if (!field2) return null;
83
100
  const value = data[field2];
84
- return typeof value === "string" || typeof value === "number" ? String(value) : null;
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;
85
105
  }
86
106
  function visibleFields(form, data) {
87
107
  const fields = allFields(form);
@@ -370,6 +390,75 @@ function isPossiblePhone(value) {
370
390
  if (country?.lengths.length) return country.lengths.includes(national.length);
371
391
  return national.length >= 4;
372
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
+ }
373
462
 
374
463
  // src/validation.ts
375
464
  var fileValueSchema = z.object({
@@ -389,7 +478,7 @@ function isEmpty(value) {
389
478
  }
390
479
  function validateField(field2, value) {
391
480
  if (isEmpty(value)) {
392
- return field2.required ? "This field is required" : null;
481
+ return field2.required ? REQUIRED_FIELD_MESSAGE : null;
393
482
  }
394
483
  switch (field2.kind) {
395
484
  case "short_text":
@@ -402,11 +491,16 @@ function validateField(field2, value) {
402
491
  return null;
403
492
  }
404
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`;
405
497
  const r = z.email().safeParse(value);
406
498
  return r.success ? null : "Enter a valid email address";
407
499
  }
408
500
  case "url": {
409
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`;
410
504
  try {
411
505
  const url = new URL(value);
412
506
  if (url.protocol !== "http:" && url.protocol !== "https:") {
@@ -441,9 +535,13 @@ function validateField(field2, value) {
441
535
  const ids = new Set(field2.options.map((o) => o.id));
442
536
  const arr = value;
443
537
  let others = 0;
538
+ const seen = /* @__PURE__ */ new Set();
444
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);
445
543
  if (ids.has(v)) continue;
446
- if (field2.allowOther !== true || typeof v !== "string" || v.length === 0 || v.length > 500)
544
+ if (field2.allowOther !== true || v.length === 0 || v.length > 500)
447
545
  return "Pick valid options";
448
546
  if (++others > 1) return "Only one 'Other' answer is allowed";
449
547
  }
@@ -535,6 +633,7 @@ function validateResponse(form, data) {
535
633
  }
536
634
  function normalizeValue(field2, value) {
537
635
  if (value === void 0 || value === null) return value;
636
+ if (field2.kind === "custom") return value;
538
637
  if (field2.kind === "number" && typeof value === "string" && value.trim() !== "") {
539
638
  const n = Number(value);
540
639
  return Number.isNaN(n) ? value : n;
@@ -600,7 +699,8 @@ var schemaShape = z2.object({
600
699
  });
601
700
  var MAX_SCHEMA_VERSION = 1;
602
701
  var FILLO_SCHEMA_VERSION = 1;
603
- var FILLO_SDK_VERSION = true ? "0.7.0" : "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";
604
704
  function str(value, max, fallback = "") {
605
705
  return typeof value === "string" ? value.trim().slice(0, max) : fallback;
606
706
  }
@@ -630,6 +730,14 @@ function options(value, max = 200) {
630
730
  return id && label ? [cleanObject({ id, label, icon })] : [];
631
731
  });
632
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
+ }
633
741
  function conditions(value) {
634
742
  if (!Array.isArray(value)) return void 0;
635
743
  const normalized = value.slice(0, 20).flatMap((item) => {
@@ -769,17 +877,25 @@ function normalizeBlock(input) {
769
877
  case "signature":
770
878
  case "date":
771
879
  return cleanObject(baseField(rec, kind));
772
- case "rating":
773
- 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
+ }
774
888
  case "linear_scale": {
775
889
  const min = int(rec.min, 0, 9, 1);
776
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;
777
892
  return cleanObject({
778
893
  ...baseField(rec, kind),
779
894
  min,
780
895
  max,
781
896
  minLabel: optionalStr(rec.minLabel, 100),
782
- maxLabel: optionalStr(rec.maxLabel, 100)
897
+ maxLabel: optionalStr(rec.maxLabel, 100),
898
+ insightsMetric
783
899
  });
784
900
  }
785
901
  case "ranking": {
@@ -838,16 +954,41 @@ function normalizeFormSchema(input) {
838
954
  return block ? [block] : [];
839
955
  });
840
956
  return cleanObject({
841
- id: page.id,
957
+ id: str(page.id, 128),
842
958
  title: optionalStr(page.title, 500),
843
959
  blocks
844
960
  });
845
961
  });
962
+ const pageIds = /* @__PURE__ */ new Set();
846
963
  const seen = /* @__PURE__ */ new Set();
847
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);
848
968
  for (const block of page.blocks) {
849
969
  if (seen.has(block.id)) return { ok: false, error: `Duplicate block id: ${block.id}` };
850
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
+ }
851
992
  }
852
993
  }
853
994
  if (pages.every((page) => page.blocks.length === 0)) {
@@ -949,6 +1090,9 @@ function formatAnswer(field2, value) {
949
1090
  var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
950
1091
  var MASK = 256 - 256 % ALPHABET.length;
951
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
+ }
952
1096
  const csprng = globalThis.crypto;
953
1097
  if (typeof csprng === "undefined" || typeof csprng.getRandomValues !== "function") {
954
1098
  throw new Error("createId requires Web Crypto (globalThis.crypto.getRandomValues)");
@@ -978,12 +1122,12 @@ var BLOCK_KIND_META = {
978
1122
  dropdown: { label: "Dropdown", hint: "Select from a list" },
979
1123
  checkbox: { label: "Yes / No", hint: "Single checkbox" },
980
1124
  rating: { label: "Rating", hint: "1\u20135 stars" },
981
- linear_scale: { label: "Linear scale", hint: "1\u201310 with end labels" },
1125
+ linear_scale: { label: "Linear scale", hint: "0\u201310 with end labels" },
982
1126
  ranking: { label: "Ranking", hint: "Order options by preference" },
983
1127
  matrix: { label: "Matrix", hint: "Grid of rows \xD7 choices" },
984
1128
  signature: { label: "Signature", hint: "Draw to sign" },
985
1129
  date: { label: "Date", hint: "Date picker" },
986
- file_upload: { label: "File upload", hint: "Huge files, resumable" },
1130
+ file_upload: { label: "File upload", hint: "Direct to connected storage" },
987
1131
  hidden: { label: "Hidden field", hint: "Filled from the URL" },
988
1132
  custom: { label: "Custom", hint: "Your own component (code only)" },
989
1133
  heading: { label: "Heading", hint: "Section title" },
@@ -1018,10 +1162,11 @@ function createBlock(kind) {
1018
1162
  id,
1019
1163
  kind,
1020
1164
  label: "How likely are you to recommend us?",
1021
- min: 1,
1165
+ min: 0,
1022
1166
  max: 10,
1023
- minLabel: "Not likely",
1024
- maxLabel: "Very likely"
1167
+ minLabel: "Not at all likely",
1168
+ maxLabel: "Extremely likely",
1169
+ insightsMetric: "nps"
1025
1170
  };
1026
1171
  case "ranking":
1027
1172
  return {
@@ -1137,12 +1282,21 @@ function assembleBlock(spec) {
1137
1282
  break;
1138
1283
  case "rating":
1139
1284
  block.max = clampInt(spec.max, 3, 10, 5);
1285
+ if (spec.insightsMetric === "csat" && block.max === 5) {
1286
+ block.insightsMetric = "csat";
1287
+ }
1140
1288
  break;
1141
1289
  case "linear_scale":
1142
1290
  block.min = spec.min === 0 ? 0 : 1;
1143
1291
  block.max = clampInt(spec.max, 2, 10, 10);
1144
1292
  if (spec.minLabel) block.minLabel = str2(spec.minLabel, 100);
1145
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
+ }
1146
1300
  break;
1147
1301
  case "number":
1148
1302
  if (typeof spec.min === "number") block.min = spec.min;
@@ -1280,25 +1434,30 @@ var sha1Base64 = (bytes) => bytesToBase64(new Sha1().update(bytes).digest());
1280
1434
 
1281
1435
  // src/client.ts
1282
1436
  var FilloError = class extends Error {
1283
- constructor(message, status, retryAfterSec2) {
1437
+ constructor(message, status, retryAfterSec2, code) {
1284
1438
  super(message);
1285
1439
  this.status = status;
1286
1440
  this.retryAfterSec = retryAfterSec2;
1441
+ this.code = code;
1287
1442
  this.name = "FilloError";
1288
1443
  }
1289
1444
  status;
1290
1445
  retryAfterSec;
1446
+ code;
1291
1447
  };
1292
1448
  function isFilloError(err) {
1293
1449
  return err instanceof Error && err.name === "FilloError";
1294
1450
  }
1295
- async function errorMessage(res, fallback) {
1451
+ async function errorDetails(res, fallback) {
1296
1452
  try {
1297
1453
  const body = await res.json();
1298
- 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
+ };
1299
1458
  } catch {
1300
1459
  }
1301
- return fallback;
1460
+ return { message: fallback };
1302
1461
  }
1303
1462
  function retryAfterSec(res) {
1304
1463
  const raw = Number(res.headers.get("retry-after"));
@@ -1306,11 +1465,31 @@ function retryAfterSec(res) {
1306
1465
  }
1307
1466
  var MAX_CHUNK_RETRIES = 3;
1308
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
+ }
1309
1488
  function isRetryableStatus(status) {
1310
1489
  return status >= 500 || status === 429 || status === 408;
1311
1490
  }
1312
1491
  function asRequestError(err) {
1313
- if (err instanceof DOMException && err.name === "TimeoutError")
1492
+ if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "TimeoutError")
1314
1493
  return new FilloError("Request timed out", 0);
1315
1494
  return err;
1316
1495
  }
@@ -1381,8 +1560,8 @@ var FilloClient = class {
1381
1560
  throw asRequestError(err);
1382
1561
  }
1383
1562
  if (!res.ok) {
1384
- const message = await errorMessage(res, `Request failed: ${res.status}`);
1385
- 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);
1386
1565
  }
1387
1566
  return await res.json();
1388
1567
  }
@@ -1393,8 +1572,10 @@ var FilloClient = class {
1393
1572
  return form;
1394
1573
  }
1395
1574
  /**
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.
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.
1398
1579
  */
1399
1580
  syncForm(handle, schema, theme) {
1400
1581
  if (!this.key) {
@@ -1434,8 +1615,8 @@ var FilloClient = class {
1434
1615
  return { ok: false, errors: body2.errors };
1435
1616
  }
1436
1617
  if (!res.ok) {
1437
- const message = await errorMessage(res, `Submit failed: ${res.status}`);
1438
- 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);
1439
1620
  }
1440
1621
  const body = await res.json();
1441
1622
  return { ok: true, responseId: body.id, duplicate: body.duplicate, updated: body.updated };
@@ -1445,12 +1626,18 @@ var FilloClient = class {
1445
1626
  * used to prefill their answers for editing. Requires a VERIFIED identity
1446
1627
  * ({id, hash}); anything else 404s (returned as null), because an
1447
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.
1448
1635
  */
1449
- async fetchOwnResponse(formId, respondent) {
1636
+ async fetchOwnResponse(formId, respondent, scopeValue) {
1450
1637
  try {
1451
1638
  return await this.json(
1452
1639
  `/forms/${encodeURIComponent(formId)}/respondent-response`,
1453
- { method: "POST", body: JSON.stringify({ respondent }) }
1640
+ { method: "POST", body: JSON.stringify({ respondent, scopeValue }) }
1454
1641
  );
1455
1642
  } catch (err) {
1456
1643
  if (isFilloError(err) && (err.status === 404 || err.status === 403)) return null;
@@ -1530,18 +1717,20 @@ var FilloClient = class {
1530
1717
  });
1531
1718
  }
1532
1719
  /** Current state of an upload session — used to resume after interruption. */
1533
- getUploadSession(sessionId, token) {
1720
+ getUploadSession(sessionId, token, signal = AbortSignal.timeout(UPLOAD_CONTROL_TIMEOUT_MS)) {
1534
1721
  return this.json(
1535
1722
  `/uploads/${encodeURIComponent(sessionId)}`,
1536
- token ? { headers: { "X-Fillo-Upload-Token": token } } : void 0
1723
+ {
1724
+ ...token ? { headers: { "X-Fillo-Upload-Token": token } } : {},
1725
+ signal
1726
+ }
1537
1727
  );
1538
1728
  }
1539
1729
  /**
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.
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.
1545
1734
  *
1546
1735
  * Depending on the form's storage settings the server picks a transport:
1547
1736
  * direct-to-Google-Drive resumable
@@ -1549,28 +1738,68 @@ var FilloClient = class {
1549
1738
  * form owner's Drive and never touch the Fillo server.
1550
1739
  */
1551
1740
  async uploadFile(formId, file, options2) {
1552
- const name = file instanceof File ? file.name : "upload.bin";
1553
- 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();
1554
1743
  let session;
1555
1744
  if (options2.sessionId) {
1556
- 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;
1557
1755
  } else {
1558
- session = await this.json(
1756
+ const requestId = createId(24);
1757
+ const requestToken = createId(32);
1758
+ const createSession = () => this.json(
1559
1759
  `/forms/${encodeURIComponent(formId)}/uploads`,
1560
1760
  {
1561
1761
  method: "POST",
1562
1762
  body: JSON.stringify({
1763
+ requestId,
1764
+ uploadToken: requestToken,
1563
1765
  fieldId: options2.fieldId,
1564
1766
  fileName: name,
1565
1767
  size: file.size,
1566
1768
  mime
1567
- })
1769
+ }),
1770
+ signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
1568
1771
  }
1569
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
+ }
1570
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);
1571
1798
  let completeBody;
1572
1799
  if (session.transport?.type === "gdrive") {
1573
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);
1574
1803
  } else if (session.transport?.type === "s3-put") {
1575
1804
  await this.s3PutUpload(session.transport.uploadUrl, file, options2);
1576
1805
  } else if (session.transport?.type === "box") {
@@ -1578,14 +1807,52 @@ var FilloClient = class {
1578
1807
  } else {
1579
1808
  throw new FilloError("This form's storage doesn't support uploads \u2014 connect Drive, S3, or Box.");
1580
1809
  }
1581
- const done = await this.json(
1810
+ const finalize = () => this.json(
1582
1811
  `/uploads/${encodeURIComponent(session.id)}/complete`,
1583
1812
  {
1584
1813
  method: "POST",
1585
1814
  headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
1586
- ...completeBody ? { body: JSON.stringify(completeBody) } : {}
1815
+ ...completeBody ? { body: JSON.stringify(completeBody) } : {},
1816
+ signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
1587
1817
  }
1588
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
+ }
1589
1856
  if (!done.file) throw new FilloError("Upload completed but no file was returned");
1590
1857
  return done.file;
1591
1858
  }
@@ -1694,10 +1961,10 @@ var FilloClient = class {
1694
1961
  /**
1695
1962
  * Caller signal (if any) combined with a fresh per-request timeout. Created per
1696
1963
  * fetch so each retry attempt gets its own deadline; the caller's own abort
1697
- * still drives the resumable-upload cancel semantics.
1964
+ * still drives upload cancellation across every provider transport.
1698
1965
  */
1699
- uploadSignal(options2) {
1700
- const timeout = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
1966
+ uploadSignal(options2, timeoutMs = DEFAULT_TIMEOUT_MS) {
1967
+ const timeout = AbortSignal.timeout(timeoutMs);
1701
1968
  return options2.signal ? AbortSignal.any([options2.signal, timeout]) : timeout;
1702
1969
  }
1703
1970
  /** Retry a request with exponential backoff; never retries an aborted upload. */
@@ -1746,7 +2013,13 @@ var FilloClient = class {
1746
2013
  const match = range?.match(/bytes=0-(\d+)/);
1747
2014
  if (match) offset = Number(match[1]) + 1;
1748
2015
  else if (range == null) offset = 0;
1749
- 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
+ }
1750
2023
  break;
1751
2024
  }
1752
2025
  if (res.ok) {
@@ -1759,7 +2032,11 @@ var FilloClient = class {
1759
2032
  attempt += 1;
1760
2033
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
1761
2034
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
1762
- 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
+ );
1763
2040
  offset = fresh.uploadedBytes;
1764
2041
  if (offset >= file.size) break;
1765
2042
  end = Math.min(offset + session.chunkSize, file.size);
@@ -1776,6 +2053,80 @@ var FilloClient = class {
1776
2053
  }
1777
2054
  }
1778
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
+ }
1779
2130
  /**
1780
2131
  * S3-compatible single PUT to a presigned URL — bytes go straight to the
1781
2132
  * bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
@@ -1866,7 +2217,7 @@ function prefillFromParams(form, params) {
1866
2217
  }
1867
2218
  case "multi_select": {
1868
2219
  const ids = new Set(field2.options.map((o) => o.id));
1869
- const picked = raw.split(",").filter((v) => ids.has(v));
2220
+ const picked = [...new Set(raw.split(",").filter((v) => ids.has(v)))];
1870
2221
  if (picked.length > 0) data[field2.id] = picked;
1871
2222
  break;
1872
2223
  }
@@ -2006,6 +2357,14 @@ function submitFailureMessage(err) {
2006
2357
  if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
2007
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.";
2008
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
+ }
2009
2368
  function createFormController(options2) {
2010
2369
  const { onChange, onSubmitted } = options2;
2011
2370
  const initial = normalizeFormSchema(options2.form);
@@ -2019,6 +2378,7 @@ function createFormController(options2) {
2019
2378
  let pageIndex = 0;
2020
2379
  let status = "idle";
2021
2380
  let restoredSubmission = false;
2381
+ let resumeLinkFailed = false;
2022
2382
  queueMicrotask(() => {
2023
2383
  if (typeof location !== "undefined" && location.search) {
2024
2384
  const params = Object.fromEntries(new URLSearchParams(location.search));
@@ -2054,7 +2414,11 @@ function createFormController(options2) {
2054
2414
  history.replaceState(null, "", location.pathname + location.search);
2055
2415
  } catch {
2056
2416
  }
2057
- }).catch(() => {
2417
+ }).catch((err) => {
2418
+ if (isDraftGone(err)) {
2419
+ resumeLinkFailed = true;
2420
+ notify();
2421
+ }
2058
2422
  }).finally(() => maybeRestoreDraft());
2059
2423
  }
2060
2424
  }
@@ -2069,6 +2433,7 @@ function createFormController(options2) {
2069
2433
  let sessionStarted = false;
2070
2434
  let resumedDraft = false;
2071
2435
  let editingPrevious = false;
2436
+ let submissionKind = "created";
2072
2437
  let editAttempted = false;
2073
2438
  let draftRef = null;
2074
2439
  let draftTimer = null;
@@ -2088,14 +2453,20 @@ function createFormController(options2) {
2088
2453
  pageIndex: clamped,
2089
2454
  pageCount,
2090
2455
  page,
2091
- 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),
2092
2460
  isFirstPage: clamped === 0,
2093
2461
  isLastPage: clamped === pageCount - 1,
2094
2462
  uploading: uploadingFields.size > 0,
2095
2463
  submitError,
2096
2464
  restoredSubmission,
2097
2465
  resumedDraft,
2098
- editingPrevious
2466
+ editingPrevious,
2467
+ duplicateSubmission: submissionKind === "duplicate",
2468
+ updatedSubmission: submissionKind === "updated",
2469
+ resumeLinkFailed
2099
2470
  };
2100
2471
  }
2101
2472
  let snapshot = computeSnapshot();
@@ -2114,7 +2485,7 @@ function createFormController(options2) {
2114
2485
  const target = form.pages[index];
2115
2486
  if (!target) return {};
2116
2487
  const pageErrors = {};
2117
- for (const block of visibleBlocks(target, data)) {
2488
+ for (const block of visiblePageBlocks(form, target, data)) {
2118
2489
  if (!isField(block)) continue;
2119
2490
  const error = validateField(block, data[block.id]);
2120
2491
  if (error) pageErrors[block.id] = error;
@@ -2191,7 +2562,8 @@ function createFormController(options2) {
2191
2562
  if (!client || !formId || !respondent?.hash || status !== "idle") return;
2192
2563
  editAttempted = true;
2193
2564
  const c = client;
2194
- void c.fetchOwnResponse(formId, respondent).then((own) => {
2565
+ const scopeValue = responseScopeValue(form.settings, data) ?? void 0;
2566
+ void c.fetchOwnResponse(formId, respondent, scopeValue).then((own) => {
2195
2567
  if (!own || status !== "idle" || sessionStarted || resumedDraft) return;
2196
2568
  applyDraftSnapshot({ data: own.data, page: 0 });
2197
2569
  if (resumedDraft) {
@@ -2315,13 +2687,13 @@ function createFormController(options2) {
2315
2687
  if (!result.ok) {
2316
2688
  errors = result.errors;
2317
2689
  const firstBad = form.pages.findIndex(
2318
- (p) => visibleBlocks(p, data).some((b) => b.id in result.errors)
2690
+ (p) => visiblePageBlocks(form, p, data).some((b) => b.id in result.errors)
2319
2691
  );
2320
2692
  if (firstBad >= 0) pageIndex = firstBad;
2321
2693
  notify();
2322
2694
  return;
2323
2695
  }
2324
- if (client && !formId && options2.resolveFormId) {
2696
+ if (client && options2.resolveFormId) {
2325
2697
  status = "submitting";
2326
2698
  submitError = void 0;
2327
2699
  notify();
@@ -2329,7 +2701,7 @@ function createFormController(options2) {
2329
2701
  formId = await options2.resolveFormId();
2330
2702
  } catch (err) {
2331
2703
  status = "idle";
2332
- submitError = submitFailureMessage(err);
2704
+ submitError = syncResolutionFailureMessage(err);
2333
2705
  notify();
2334
2706
  throw err;
2335
2707
  }
@@ -2359,10 +2731,16 @@ function createFormController(options2) {
2359
2731
  }
2360
2732
  if (!res.ok) {
2361
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.";
2362
2739
  status = "idle";
2363
2740
  notify();
2364
2741
  return;
2365
2742
  }
2743
+ submissionKind = res.duplicate ? "duplicate" : res.updated ? "updated" : "created";
2366
2744
  if (sessionId) client.reportProgress(sessionId, { completed: true });
2367
2745
  if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
2368
2746
  discardDraftState();
@@ -2449,9 +2827,8 @@ function isAutoSubmitBlock(block) {
2449
2827
  return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
2450
2828
  }
2451
2829
  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;
2830
+ const interactive = visible.filter((b) => isField(b) && b.kind !== "hidden");
2831
+ if (interactive.length !== 1) return true;
2455
2832
  return !isAutoSubmitBlock(interactive[0]);
2456
2833
  }
2457
2834
  function shouldAutoSubmit(field2, value, ctx) {
@@ -2549,35 +2926,6 @@ var FILLO_THEME_VARS = [
2549
2926
  { var: "--fillo-primary-contrast" }
2550
2927
  ];
2551
2928
 
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
2929
  // src/define.ts
2582
2930
  function defineForm(def) {
2583
2931
  return {
@@ -2598,15 +2946,18 @@ function isCodeForm(form) {
2598
2946
  }
2599
2947
  var syncCache = /* @__PURE__ */ new Map();
2600
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
+ }
2601
2954
  async function syncWithRetry(client, form) {
2602
2955
  let delay = 800;
2603
2956
  for (let attempt = 0; ; attempt++) {
2604
2957
  try {
2605
2958
  return await client.syncForm(form.id, form.schema, form.theme ?? null);
2606
2959
  } 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;
2960
+ if (!isRetryableSyncError(err) || attempt >= 2) throw err;
2610
2961
  const hinted = isFilloError(err) ? err.retryAfterSec : void 0;
2611
2962
  await sleep(Math.min(hinted ? hinted * 1e3 : delay * (1 + Math.random() * 0.5), 15e3));
2612
2963
  delay *= 2;
@@ -2618,17 +2969,32 @@ function contentHash(input) {
2618
2969
  for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) | 0;
2619
2970
  return (h >>> 0).toString(36);
2620
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);
2621
2987
  function storageKey(client, handle) {
2622
- return `fillo:sync:${client.baseUrl}|${client.key}|${handle}`;
2988
+ return `fillo:sync:v2:${client.baseUrl}|${client.key}|${handle}`;
2623
2989
  }
2624
2990
  function readStoredSync(client, handle, hash) {
2625
2991
  try {
2626
2992
  const raw = globalThis.localStorage?.getItem(storageKey(client, handle));
2627
2993
  if (!raw) return null;
2628
2994
  const parsed = JSON.parse(raw);
2629
- 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")
2630
2996
  return null;
2631
- const ttl = parsed.status === "published" ? 36e5 : 6e4;
2997
+ const ttl = syncTtlMs(parsed.status);
2632
2998
  if (Date.now() - parsed.ts > ttl) return null;
2633
2999
  return parsed;
2634
3000
  } catch {
@@ -2636,6 +3002,7 @@ function readStoredSync(client, handle, hash) {
2636
3002
  }
2637
3003
  }
2638
3004
  function writeStoredSync(client, handle, hash, r) {
3005
+ if (r.staged || r.resolvedSchema || r.syncError) return;
2639
3006
  try {
2640
3007
  globalThis.localStorage?.setItem(
2641
3008
  storageKey(client, handle),
@@ -2654,23 +3021,44 @@ function syncCodeForm(client, form, opts) {
2654
3021
  const content2 = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
2655
3022
  const hash = contentHash(content2);
2656
3023
  const key = `${client.baseUrl}:${client.key}:${form.id}:${hash}`;
2657
- let pending = syncCache.get(key);
2658
- 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
+ }
2659
3029
  if (!opts?.bypassCache) {
2660
3030
  const stored = readStoredSync(client, form.id, hash);
2661
3031
  if (stored) {
2662
- pending = Promise.resolve({ formId: stored.formId, slug: stored.slug, status: stored.status });
2663
- syncCache.set(key, pending);
2664
- 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;
2665
3042
  }
2666
3043
  }
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;
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;
2674
3062
  }
2675
3063
 
2676
3064
  // src/jsx.ts
@@ -2710,8 +3098,8 @@ var JSX_BLOCK_SPECS = [
2710
3098
  field("MultiSelect", "multi_select", CHOICE_EXTRA, { optionChildren: true }),
2711
3099
  field("Dropdown", "dropdown", CHOICE_EXTRA, { optionChildren: true }),
2712
3100
  field("Checkbox", "checkbox", ["appearance"]),
2713
- field("Rating", "rating", ["max"]),
2714
- field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel"]),
3101
+ field("Rating", "rating", ["max", "insightsMetric"]),
3102
+ field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel", "insightsMetric"]),
2715
3103
  field("Ranking", "ranking", ["options"], { optionChildren: true }),
2716
3104
  field("Matrix", "matrix", ["rows", "columns"]),
2717
3105
  field("Signature", "signature", []),
@@ -2876,6 +3264,18 @@ function buildOption(el, parent, parentId) {
2876
3264
  if (props.icon !== void 0) option.icon = props.icon;
2877
3265
  return option;
2878
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
+ }
2879
3279
  function schemaFromJsx(children, meta) {
2880
3280
  const elements = flatten(children);
2881
3281
  const pageSpecs = elements.map((el) => ({ el, spec: classify(el) }));
@@ -2916,7 +3316,15 @@ function schemaFromJsx(children, meta) {
2916
3316
  pages.push({ id: "main", blocks });
2917
3317
  }
2918
3318
  const seen = /* @__PURE__ */ new Set();
3319
+ const pageIds = /* @__PURE__ */ new Set();
2919
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);
2920
3328
  for (const block of page.blocks) {
2921
3329
  if (seen.has(block.id)) {
2922
3330
  throw new FilloJsxError(
@@ -2925,6 +3333,11 @@ function schemaFromJsx(children, meta) {
2925
3333
  );
2926
3334
  }
2927
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
+ }
2928
3341
  }
2929
3342
  }
2930
3343
  return {
@@ -2999,9 +3412,11 @@ function pipeBlock(block, data, form) {
2999
3412
  export {
3000
3413
  BLOCK_KIND_META,
3001
3414
  CONTENT_KINDS,
3415
+ DEFAULT_FIELD_STRINGS,
3002
3416
  DEFAULT_STRINGS,
3003
3417
  DRAFT_KINDS,
3004
3418
  FILLO_DATA_ATTRS,
3419
+ FILLO_MIN_SDK_VERSION,
3005
3420
  FILLO_SCHEMA_VERSION,
3006
3421
  FILLO_SDK_VERSION,
3007
3422
  FILLO_SLOTS,
@@ -3012,6 +3427,8 @@ export {
3012
3427
  JSX_BLOCK_COMPONENTS,
3013
3428
  JSX_BLOCK_SPECS,
3014
3429
  PHONE_COUNTRIES,
3430
+ PHONE_POPOVER_VIEWPORT_GAP,
3431
+ REQUIRED_FIELD_MESSAGE,
3015
3432
  Sha1,
3016
3433
  allFields,
3017
3434
  assembleForm,
@@ -3028,6 +3445,7 @@ export {
3028
3445
  defineForm,
3029
3446
  digitsOnly,
3030
3447
  flagEmoji,
3448
+ formSchemasEqual,
3031
3449
  formatAnswer,
3032
3450
  formatNational,
3033
3451
  isAutoSubmitBlock,
@@ -3042,6 +3460,7 @@ export {
3042
3460
  normalizeSettings,
3043
3461
  parsePhone,
3044
3462
  pipeBlock,
3463
+ positionPhonePopover,
3045
3464
  prefillFromParams,
3046
3465
  provisionWorkspace,
3047
3466
  resolveSlotClass,
@@ -3059,5 +3478,6 @@ export {
3059
3478
  validateResponse,
3060
3479
  visibleBlocks,
3061
3480
  visibleFields,
3481
+ visiblePageBlocks,
3062
3482
  when
3063
3483
  };