@usefillo/core 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -39,8 +39,9 @@ interface PhoneField extends BaseField {
39
39
  kind: "phone";
40
40
  /**
41
41
  * Country selected when the form opens (ISO-3166 alpha-2, e.g. "US"). When
42
- * unset the renderer falls back to the respondent's browser locale, then the
43
- * first country in the list. The respondent can always change it.
42
+ * unset the renderer falls back to the respondent's browser timezone, browser
43
+ * locale, then the first country in the list. The respondent can always
44
+ * change it.
44
45
  */
45
46
  defaultCountry?: string;
46
47
  }
@@ -351,6 +352,7 @@ declare const PHONE_COUNTRIES: PhoneCountry[];
351
352
  /** Flag emoji from an ISO-3166 alpha-2 code (regional-indicator letters). */
352
353
  declare function flagEmoji(iso2: string): string;
353
354
  declare function countryByIso(iso2: string | undefined): PhoneCountry | undefined;
355
+ declare function countryByTimeZone(timeZone: string | undefined): PhoneCountry | undefined;
354
356
  /** Country whose dial code is the longest prefix of these digits (no "+"). */
355
357
  declare function countryByDialCode(digits: string): PhoneCountry | undefined;
356
358
  /** Keep only 0-9 (and a leading "+") from arbitrary input. */
@@ -465,14 +467,19 @@ interface PublishedForm {
465
467
  /** Workspace branding state — absent means show the badge (default). */
466
468
  branding?: FormBranding;
467
469
  }
468
- interface SubmitResult {
469
- ok: boolean;
470
- responseId?: string;
470
+ type SubmitResult = {
471
+ ok: true;
472
+ responseId: string;
471
473
  /** True when the API accepted the request as an already-recorded visitor response. */
472
474
  duplicate?: boolean;
475
+ errors?: undefined;
476
+ } | {
477
+ ok: false;
473
478
  /** fieldId -> message when the server rejects the submission. */
474
- errors?: Record<string, string>;
475
- }
479
+ errors: Record<string, string>;
480
+ responseId?: undefined;
481
+ duplicate?: undefined;
482
+ };
476
483
  /** Anti-spam signals collected by the renderer. */
477
484
  interface SubmitMeta {
478
485
  /** Honeypot value — must be empty for humans. */
@@ -561,6 +568,12 @@ declare class FilloClient {
561
568
  * Box; we return the new file id for /complete to verify server-side.
562
569
  */
563
570
  private boxUpload;
571
+ /**
572
+ * Caller signal (if any) combined with a fresh per-request timeout. Created per
573
+ * fetch so each retry attempt gets its own deadline; the caller's own abort
574
+ * still drives the resumable-upload cancel semantics.
575
+ */
576
+ private uploadSignal;
564
577
  /** Retry a request with exponential backoff; never retries an aborted upload. */
565
578
  private retry;
566
579
  /**
@@ -637,8 +650,9 @@ interface FormControllerOptions {
637
650
  /**
638
651
  * Embedding surface. "headless" (a bare engine with no Fillo-rendered layout,
639
652
  * i.e. createFormController / <FilloProvider>) is a paid capability the server
640
- * enforces at submit. The framed renderers (<FilloForm>, renderForm) are
641
- * "default". Defaults to "default".
653
+ * enforces at submit. The framed renderers (<FilloForm>, renderForm) pass
654
+ * "default" explicitly. Defaults to "headless" — a bare createFormController is
655
+ * itself the headless gate.
642
656
  */
643
657
  surface?: "default" | "headless";
644
658
  }
@@ -765,4 +779,4 @@ declare class Sha1 {
765
779
  }
766
780
  declare const sha1Base64: (bytes: Uint8Array) => string;
767
781
 
768
- export { BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CustomField, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, FilloClient, type FilloClientOptions, FilloError, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, type JsonValue, type LinearScaleField, type MatrixField, type NumberField, PHONE_COUNTRIES, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type ProvisionWorkspaceResult, type PublishedForm, type RankingField, type RatingField, type ResponseData, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SubmitMeta, type SubmitResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, allFields, assembleForm, contentHash, countryByDialCode, countryByIso, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formatAnswer, formatNational, isBlockVisible, isCodeForm, isField, isPossiblePhone, normalizeFormSchema, normalizeFormTheme, parsePhone, pipeBlock, prefillFromParams, provisionWorkspace, resolveText, sha1Base64, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
782
+ export { BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CustomField, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, FilloClient, type FilloClientOptions, FilloError, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, type JsonValue, type LinearScaleField, type MatrixField, type NumberField, PHONE_COUNTRIES, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type ProvisionWorkspaceResult, type PublishedForm, type RankingField, type RatingField, type ResponseData, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SubmitMeta, type SubmitResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, allFields, assembleForm, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formatAnswer, formatNational, isBlockVisible, isCodeForm, isField, isPossiblePhone, normalizeFormSchema, normalizeFormTheme, parsePhone, pipeBlock, prefillFromParams, provisionWorkspace, resolveText, sha1Base64, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
package/dist/index.js CHANGED
@@ -13,8 +13,16 @@ function isAnswered(value) {
13
13
  if (typeof value === "object") return Object.keys(value).length > 0;
14
14
  return true;
15
15
  }
16
- function evalCondition(cond, data) {
17
- const value = data[cond.fieldId];
16
+ function valueEquals(value, target) {
17
+ if (value === target) return true;
18
+ if (typeof value === "number" && typeof target === "string" && target.trim() !== "")
19
+ return Number(target) === value;
20
+ if (typeof target === "number" && typeof value === "string" && value.trim() !== "")
21
+ return Number(value) === target;
22
+ return false;
23
+ }
24
+ function evalCondition(cond, resolve) {
25
+ const value = resolve(cond.fieldId);
18
26
  switch (cond.op) {
19
27
  case "answered":
20
28
  return isAnswered(value);
@@ -22,10 +30,11 @@ function evalCondition(cond, data) {
22
30
  return !isAnswered(value);
23
31
  case "eq":
24
32
  if (Array.isArray(value)) return value.some((v) => v === cond.value);
25
- return value === cond.value;
33
+ return valueEquals(value, cond.value);
26
34
  case "neq":
35
+ if (!isAnswered(value)) return false;
27
36
  if (Array.isArray(value)) return !value.some((v) => v === cond.value);
28
- return value !== cond.value;
37
+ return !valueEquals(value, cond.value);
29
38
  case "contains":
30
39
  if (typeof value === "string")
31
40
  return value.toLowerCase().includes(String(cond.value ?? "").toLowerCase());
@@ -37,22 +46,45 @@ function evalCondition(cond, data) {
37
46
  return typeof value === "number" && value < Number(cond.value);
38
47
  }
39
48
  }
40
- function isBlockVisible(block, data) {
49
+ function blockVisibleWith(block, resolve) {
41
50
  if (!block.visibleIf || block.visibleIf.length === 0) return true;
42
- return block.visibleIf.every((cond) => evalCondition(cond, data));
51
+ return block.visibleIf.every((cond) => evalCondition(cond, resolve));
52
+ }
53
+ function makeResolver(data, scoped, visible) {
54
+ return (fieldId) => scoped.has(fieldId) && !visible.has(fieldId) ? void 0 : data[fieldId];
55
+ }
56
+ function visibleFieldIds(fields, data) {
57
+ const scoped = new Set(fields.map((f) => f.id));
58
+ let visible = scoped;
59
+ for (let i = 0; i <= fields.length; i++) {
60
+ const resolve = makeResolver(data, scoped, visible);
61
+ const next = /* @__PURE__ */ new Set();
62
+ for (const f of fields) if (blockVisibleWith(f, resolve)) next.add(f.id);
63
+ if (next.size === visible.size && [...next].every((id) => visible.has(id))) return next;
64
+ visible = next;
65
+ }
66
+ return visible;
67
+ }
68
+ function isBlockVisible(block, data) {
69
+ return blockVisibleWith(block, (fieldId) => data[fieldId]);
43
70
  }
44
71
  function visibleBlocks(page, data) {
45
- return page.blocks.filter((b) => isBlockVisible(b, data));
72
+ const fields = page.blocks.filter(isField);
73
+ const scoped = new Set(fields.map((f) => f.id));
74
+ const resolve = makeResolver(data, scoped, visibleFieldIds(fields, data));
75
+ return page.blocks.filter((b) => blockVisibleWith(b, resolve));
46
76
  }
47
77
  function allFields(form) {
48
78
  return form.pages.flatMap((p) => p.blocks.filter(isField));
49
79
  }
50
80
  function visibleFields(form, data) {
51
- return form.pages.flatMap((p) => visibleBlocks(p, data).filter(isField));
81
+ const fields = allFields(form);
82
+ const visible = visibleFieldIds(fields, data);
83
+ return fields.filter((f) => visible.has(f.id));
52
84
  }
53
85
 
54
86
  // src/validation.ts
55
- import { z } from "zod";
87
+ import * as z from "zod/mini";
56
88
 
57
89
  // src/phone.ts
58
90
  var C = (iso2, name, dialCode, lengths = [], groups = [], example = "") => ({ iso2, name, dialCode, lengths, groups, example });
@@ -129,6 +161,138 @@ var PHONE_COUNTRIES = [
129
161
  C("KW", "Kuwait", "965", [8], [4, 4], "50012345")
130
162
  ];
131
163
  var byIso = new Map(PHONE_COUNTRIES.map((c) => [c.iso2, c]));
164
+ var timeZoneIso = {
165
+ "Africa/Cairo": "EG",
166
+ "Africa/Casablanca": "MA",
167
+ "Africa/Johannesburg": "ZA",
168
+ "Africa/Lagos": "NG",
169
+ "Africa/Nairobi": "KE",
170
+ "America/Adak": "US",
171
+ "America/Anchorage": "US",
172
+ "America/Argentina/Buenos_Aires": "AR",
173
+ "America/Argentina/Cordoba": "AR",
174
+ "America/Argentina/Mendoza": "AR",
175
+ "America/Bahia": "BR",
176
+ "America/Belem": "BR",
177
+ "America/Bogota": "CO",
178
+ "America/Boise": "US",
179
+ "America/Boa_Vista": "BR",
180
+ "America/Cancun": "MX",
181
+ "America/Chicago": "US",
182
+ "America/Chihuahua": "MX",
183
+ "America/Campo_Grande": "BR",
184
+ "America/Costa_Rica": "CR",
185
+ "America/Cuiaba": "BR",
186
+ "America/Denver": "US",
187
+ "America/Detroit": "US",
188
+ "America/Edmonton": "CA",
189
+ "America/Fortaleza": "BR",
190
+ "America/Guayaquil": "EC",
191
+ "America/Halifax": "CA",
192
+ "America/Hermosillo": "MX",
193
+ "America/Indiana/Indianapolis": "US",
194
+ "America/Indianapolis": "US",
195
+ "America/Juneau": "US",
196
+ "America/Kentucky/Louisville": "US",
197
+ "America/Lima": "PE",
198
+ "America/Los_Angeles": "US",
199
+ "America/Maceio": "BR",
200
+ "America/Manaus": "BR",
201
+ "America/Matamoros": "MX",
202
+ "America/Mazatlan": "MX",
203
+ "America/Merida": "MX",
204
+ "America/Mexico_City": "MX",
205
+ "America/Moncton": "CA",
206
+ "America/Monterrey": "MX",
207
+ "America/Montevideo": "UY",
208
+ "America/New_York": "US",
209
+ "America/Noronha": "BR",
210
+ "America/Nome": "US",
211
+ "America/North_Dakota/Center": "US",
212
+ "America/Ojinaga": "MX",
213
+ "America/Phoenix": "US",
214
+ "America/Porto_Velho": "BR",
215
+ "America/Punta_Arenas": "CL",
216
+ "America/Recife": "BR",
217
+ "America/Regina": "CA",
218
+ "America/Rio_Branco": "BR",
219
+ "America/Santiago": "CL",
220
+ "America/Sao_Paulo": "BR",
221
+ "America/Sitka": "US",
222
+ "America/St_Johns": "CA",
223
+ "America/Tijuana": "MX",
224
+ "America/Toronto": "CA",
225
+ "America/Vancouver": "CA",
226
+ "America/Whitehorse": "CA",
227
+ "America/Winnipeg": "CA",
228
+ "America/Yakutat": "US",
229
+ "Asia/Bangkok": "TH",
230
+ "Asia/Calcutta": "IN",
231
+ "Asia/Dacca": "BD",
232
+ "Asia/Dhaka": "BD",
233
+ "Asia/Dubai": "AE",
234
+ "Asia/Hong_Kong": "HK",
235
+ "Asia/Ho_Chi_Minh": "VN",
236
+ "Asia/Jakarta": "ID",
237
+ "Asia/Jerusalem": "IL",
238
+ "Asia/Karachi": "PK",
239
+ "Asia/Kolkata": "IN",
240
+ "Asia/Kuala_Lumpur": "MY",
241
+ "Asia/Kuwait": "KW",
242
+ "Asia/Manila": "PH",
243
+ "Asia/Qatar": "QA",
244
+ "Asia/Riyadh": "SA",
245
+ "Asia/Seoul": "KR",
246
+ "Asia/Shanghai": "CN",
247
+ "Asia/Saigon": "VN",
248
+ "Asia/Singapore": "SG",
249
+ "Asia/Taipei": "TW",
250
+ "Asia/Tel_Aviv": "IL",
251
+ "Asia/Tokyo": "JP",
252
+ "Asia/Urumqi": "CN",
253
+ "Atlantic/Reykjavik": "IS",
254
+ "Australia/Adelaide": "AU",
255
+ "Australia/Brisbane": "AU",
256
+ "Australia/Darwin": "AU",
257
+ "Australia/Hobart": "AU",
258
+ "Australia/Melbourne": "AU",
259
+ "Australia/Perth": "AU",
260
+ "Australia/Sydney": "AU",
261
+ "Europe/Amsterdam": "NL",
262
+ "Europe/Athens": "GR",
263
+ "Europe/Belgrade": "RS",
264
+ "Europe/Berlin": "DE",
265
+ "Europe/Bratislava": "SK",
266
+ "Europe/Brussels": "BE",
267
+ "Europe/Bucharest": "RO",
268
+ "Europe/Budapest": "HU",
269
+ "Europe/Copenhagen": "DK",
270
+ "Europe/Dublin": "IE",
271
+ "Europe/Helsinki": "FI",
272
+ "Europe/Zagreb": "HR",
273
+ "Europe/Istanbul": "TR",
274
+ "Europe/Kiev": "UA",
275
+ "Europe/Kyiv": "UA",
276
+ "Europe/Lisbon": "PT",
277
+ "Europe/Ljubljana": "SI",
278
+ "Europe/London": "GB",
279
+ "Europe/Luxembourg": "LU",
280
+ "Europe/Madrid": "ES",
281
+ "Europe/Moscow": "RU",
282
+ "Europe/Oslo": "NO",
283
+ "Europe/Paris": "FR",
284
+ "Europe/Prague": "CZ",
285
+ "Europe/Riga": "LV",
286
+ "Europe/Rome": "IT",
287
+ "Europe/Sofia": "BG",
288
+ "Europe/Stockholm": "SE",
289
+ "Europe/Tallinn": "EE",
290
+ "Europe/Vienna": "AT",
291
+ "Europe/Vilnius": "LT",
292
+ "Europe/Warsaw": "PL",
293
+ "Europe/Zurich": "CH",
294
+ "Pacific/Auckland": "NZ"
295
+ };
132
296
  function flagEmoji(iso2) {
133
297
  const cc = iso2.toUpperCase();
134
298
  if (!/^[A-Z]{2}$/.test(cc)) return "\u{1F3F3}\uFE0F";
@@ -137,6 +301,9 @@ function flagEmoji(iso2) {
137
301
  function countryByIso(iso2) {
138
302
  return iso2 ? byIso.get(iso2.toUpperCase()) : void 0;
139
303
  }
304
+ function countryByTimeZone(timeZone) {
305
+ return countryByIso(timeZone ? timeZoneIso[timeZone] : void 0);
306
+ }
140
307
  function countryByDialCode(digits) {
141
308
  let best;
142
309
  for (const c of PHONE_COUNTRIES) {
@@ -200,11 +367,11 @@ function isPossiblePhone(value) {
200
367
 
201
368
  // src/validation.ts
202
369
  var fileValueSchema = z.object({
203
- fileId: z.string().min(1),
370
+ fileId: z.string().check(z.minLength(1)),
204
371
  name: z.string(),
205
- size: z.number().nonnegative(),
372
+ size: z.number().check(z.nonnegative()),
206
373
  mime: z.string(),
207
- url: z.string().optional()
374
+ url: z.optional(z.string())
208
375
  });
209
376
  function isEmpty(value) {
210
377
  if (value === null || value === void 0) return true;
@@ -251,7 +418,7 @@ function validateField(field, value) {
251
418
  }
252
419
  case "number": {
253
420
  const n = typeof value === "string" ? Number(value) : value;
254
- if (typeof n !== "number" || Number.isNaN(n)) return "Enter a number";
421
+ if (typeof n !== "number" || !Number.isFinite(n)) return "Enter a number";
255
422
  if (field.min !== void 0 && n < field.min) return `Must be at least ${field.min}`;
256
423
  if (field.max !== void 0 && n > field.max) return `Must be at most ${field.max}`;
257
424
  return null;
@@ -329,6 +496,10 @@ function validateField(field, value) {
329
496
  case "date": {
330
497
  if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
331
498
  return "Enter a valid date";
499
+ const [y, m, d] = value.split("-").map(Number);
500
+ const dt = new Date(Date.UTC(y, m - 1, d));
501
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d)
502
+ return "Enter a valid date";
332
503
  return null;
333
504
  }
334
505
  case "file_upload": {
@@ -362,7 +533,7 @@ function normalizeValue(field, value) {
362
533
  const n = Number(value);
363
534
  return Number.isNaN(n) ? value : n;
364
535
  }
365
- if (field.kind === "rating" && typeof value === "string") {
536
+ if ((field.kind === "rating" || field.kind === "linear_scale") && typeof value === "string") {
366
537
  const n = Number(value);
367
538
  return Number.isNaN(n) ? value : n;
368
539
  }
@@ -371,7 +542,7 @@ function normalizeValue(field, value) {
371
542
  }
372
543
 
373
544
  // src/schema-validation.ts
374
- import { z as z2 } from "zod";
545
+ import * as z2 from "zod/mini";
375
546
  var FIELD_KINDS = [
376
547
  "short_text",
377
548
  "long_text",
@@ -395,27 +566,31 @@ var FIELD_KINDS = [
395
566
  ];
396
567
  var CONTENT_KINDS2 = ["heading", "paragraph", "divider"];
397
568
  var ALL_KINDS = [...FIELD_KINDS, ...CONTENT_KINDS2];
398
- var idSchema = z2.string().min(1).max(128);
399
- var optionSchema = z2.object({ id: idSchema, label: z2.string().max(1e3), icon: z2.unknown().optional() }).passthrough();
400
- var blockSchema = z2.object({
569
+ var idSchema = z2.string().check(z2.minLength(1), z2.maxLength(128));
570
+ var optionSchema = z2.looseObject({
571
+ id: idSchema,
572
+ label: z2.string().check(z2.maxLength(1e3)),
573
+ icon: z2.optional(z2.unknown())
574
+ });
575
+ var blockSchema = z2.looseObject({
401
576
  id: idSchema,
402
577
  kind: z2.enum(ALL_KINDS),
403
- label: z2.string().max(2e3).optional(),
404
- options: z2.array(optionSchema).max(200).optional(),
405
- rows: z2.array(optionSchema).max(200).optional(),
406
- columns: z2.array(optionSchema).max(200).optional()
407
- }).passthrough();
578
+ label: z2.optional(z2.string().check(z2.maxLength(2e3))),
579
+ options: z2.optional(z2.array(optionSchema).check(z2.maxLength(200))),
580
+ rows: z2.optional(z2.array(optionSchema).check(z2.maxLength(200))),
581
+ columns: z2.optional(z2.array(optionSchema).check(z2.maxLength(200)))
582
+ });
408
583
  var pageSchema = z2.object({
409
584
  id: idSchema,
410
- title: z2.string().max(500).optional(),
411
- blocks: z2.array(blockSchema).max(500)
585
+ title: z2.optional(z2.string().check(z2.maxLength(500))),
586
+ blocks: z2.array(blockSchema).check(z2.maxLength(500))
412
587
  });
413
588
  var schemaShape = z2.object({
414
589
  version: z2.literal(1),
415
- title: z2.string().max(2e3).optional().default(""),
416
- description: z2.string().max(5e3).optional(),
417
- pages: z2.array(pageSchema).min(1).max(50),
418
- settings: z2.record(z2.string(), z2.unknown()).optional().default({})
590
+ title: z2._default(z2.optional(z2.string().check(z2.maxLength(2e3))), ""),
591
+ description: z2.optional(z2.string().check(z2.maxLength(5e3))),
592
+ pages: z2.array(pageSchema).check(z2.minLength(1), z2.maxLength(50)),
593
+ settings: z2._default(z2.optional(z2.record(z2.string(), z2.unknown())), {})
419
594
  });
420
595
  var MAX_SCHEMA_VERSION = 1;
421
596
  var FILLO_SCHEMA_VERSION = 1;
@@ -505,7 +680,7 @@ function normalizeSettings(value) {
505
680
  redirectUrl: normalizeUrl(rec.redirectUrl),
506
681
  showProgress: bool(rec.showProgress),
507
682
  submissionLimit,
508
- notifyEmail: z2.email().max(254).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
683
+ notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
509
684
  sendReceipt: bool(rec.sendReceipt)
510
685
  };
511
686
  }
@@ -698,15 +873,20 @@ function formatAnswer(field, value) {
698
873
  if (value === null || value === void 0 || value === "") return "";
699
874
  switch (field.kind) {
700
875
  case "file_upload":
876
+ if (!Array.isArray(value)) return String(value);
701
877
  return value.map((f) => f.name).join(", ");
702
878
  case "select":
703
879
  case "dropdown":
704
880
  return field.options.find((o) => o.id === value)?.label ?? String(value);
705
881
  case "multi_select":
882
+ if (!Array.isArray(value)) return String(value);
706
883
  return value.map((id) => field.options.find((o) => o.id === id)?.label ?? id).join(", ");
707
884
  case "ranking":
885
+ if (!Array.isArray(value)) return String(value);
708
886
  return value.map((id, i) => `${i + 1}. ${field.options.find((o) => o.id === id)?.label ?? id}`).join(", ");
709
887
  case "matrix": {
888
+ if (typeof value !== "object" || value === null || Array.isArray(value))
889
+ return String(value);
710
890
  const answers = value;
711
891
  return field.rows.filter((row) => answers[row.id]).map((row) => `${row.label}: ${field.columns.find((c) => c.id === answers[row.id])?.label ?? ""}`).join(", ");
712
892
  }
@@ -1083,6 +1263,15 @@ var FilloError = class extends Error {
1083
1263
  status;
1084
1264
  };
1085
1265
  var MAX_CHUNK_RETRIES = 3;
1266
+ var DEFAULT_TIMEOUT_MS = 3e4;
1267
+ function isRetryableStatus(status) {
1268
+ return status >= 500 || status === 429 || status === 408;
1269
+ }
1270
+ function asRequestError(err) {
1271
+ if (err instanceof DOMException && err.name === "TimeoutError")
1272
+ return new FilloError("Request timed out", 0);
1273
+ return err;
1274
+ }
1086
1275
  function compareVersions(a, b) {
1087
1276
  const left = a.split(".").map((part) => Number(part) || 0);
1088
1277
  const right = b.split(".").map((part) => Number(part) || 0);
@@ -1137,10 +1326,17 @@ var FilloClient = class {
1137
1326
  return `${this.baseUrl}/api/v1${path}`;
1138
1327
  }
1139
1328
  async json(path, init) {
1140
- const res = await this.fetch(this.url(path), {
1141
- ...init,
1142
- headers: { "Content-Type": "application/json", ...init?.headers }
1143
- });
1329
+ const headers = init?.body != null ? { "Content-Type": "application/json", ...init?.headers } : init?.headers;
1330
+ let res;
1331
+ try {
1332
+ res = await this.fetch(this.url(path), {
1333
+ ...init,
1334
+ headers,
1335
+ signal: init?.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
1336
+ });
1337
+ } catch (err) {
1338
+ throw asRequestError(err);
1339
+ }
1144
1340
  if (!res.ok) {
1145
1341
  const body = await res.text().catch(() => "");
1146
1342
  throw new FilloError(body || `Request failed: ${res.status}`, res.status);
@@ -1179,11 +1375,17 @@ var FilloClient = class {
1179
1375
  }
1180
1376
  /** Submit a response. Returns per-field errors instead of throwing on validation failure. */
1181
1377
  async submit(formId, data, meta) {
1182
- const res = await this.fetch(this.url(`/forms/${encodeURIComponent(formId)}/responses`), {
1183
- method: "POST",
1184
- headers: { "Content-Type": "application/json" },
1185
- body: JSON.stringify({ data, meta })
1186
- });
1378
+ let res;
1379
+ try {
1380
+ res = await this.fetch(this.url(`/forms/${encodeURIComponent(formId)}/responses`), {
1381
+ method: "POST",
1382
+ headers: { "Content-Type": "application/json" },
1383
+ body: JSON.stringify({ data, meta }),
1384
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
1385
+ });
1386
+ } catch (err) {
1387
+ throw asRequestError(err);
1388
+ }
1187
1389
  if (res.status === 422) {
1188
1390
  const body2 = await res.json();
1189
1391
  return { ok: false, errors: body2.errors };
@@ -1203,7 +1405,8 @@ var FilloClient = class {
1203
1405
  const res = await this.fetch(this.url(`/forms/${encodeURIComponent(formId)}/sessions`), {
1204
1406
  method: "POST",
1205
1407
  headers: { "Content-Type": "application/json" },
1206
- body: JSON.stringify({ pageCount })
1408
+ body: JSON.stringify({ pageCount }),
1409
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
1207
1410
  });
1208
1411
  if (!res.ok) return null;
1209
1412
  const body = await res.json();
@@ -1219,7 +1422,8 @@ var FilloClient = class {
1219
1422
  method: "POST",
1220
1423
  headers: { "Content-Type": "application/json" },
1221
1424
  body: JSON.stringify(data),
1222
- keepalive: true
1425
+ keepalive: true,
1426
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
1223
1427
  }).catch(() => {
1224
1428
  });
1225
1429
  } catch {
@@ -1303,10 +1507,17 @@ var FilloClient = class {
1303
1507
  JSON.stringify({ name: transport.fileName, parent: { id: transport.folderId } })
1304
1508
  );
1305
1509
  form.append("file", file, transport.fileName);
1306
- const res = await this.retry(
1307
- options2,
1308
- () => this.fetch(transport.uploadUrl, { method: "POST", headers: auth, body: form, signal: options2.signal })
1309
- );
1510
+ const res = await this.retry(options2, async () => {
1511
+ const r = await this.fetch(transport.uploadUrl, {
1512
+ method: "POST",
1513
+ headers: auth,
1514
+ body: form,
1515
+ signal: this.uploadSignal(options2)
1516
+ });
1517
+ if (!r.ok && isRetryableStatus(r.status))
1518
+ throw new FilloError(`Box upload failed: ${r.status}`, r.status);
1519
+ return r;
1520
+ });
1310
1521
  if (!res.ok) throw new FilloError(`Box upload failed: ${res.status}`, res.status);
1311
1522
  const body = await res.json();
1312
1523
  report(file.size);
@@ -1318,9 +1529,8 @@ var FilloClient = class {
1318
1529
  400
1319
1530
  );
1320
1531
  }
1321
- const sessionRes = await this.retry(
1322
- options2,
1323
- () => this.fetch(transport.sessionUrl, {
1532
+ const sessionRes = await this.retry(options2, async () => {
1533
+ const r = await this.fetch(transport.sessionUrl, {
1324
1534
  method: "POST",
1325
1535
  headers: { ...auth, "Content-Type": "application/json" },
1326
1536
  body: JSON.stringify({
@@ -1328,9 +1538,12 @@ var FilloClient = class {
1328
1538
  file_size: transport.size,
1329
1539
  file_name: transport.fileName
1330
1540
  }),
1331
- signal: options2.signal
1332
- })
1333
- );
1541
+ signal: this.uploadSignal(options2)
1542
+ });
1543
+ if (!r.ok && isRetryableStatus(r.status))
1544
+ throw new FilloError(`Box session failed: ${r.status}`, r.status);
1545
+ return r;
1546
+ });
1334
1547
  if (!sessionRes.ok) throw new FilloError(`Box session failed: ${sessionRes.status}`, sessionRes.status);
1335
1548
  const session = await sessionRes.json();
1336
1549
  const whole = new Sha1();
@@ -1342,9 +1555,8 @@ var FilloClient = class {
1342
1555
  const bytes = new Uint8Array(await file.slice(offset, end).arrayBuffer());
1343
1556
  whole.update(bytes);
1344
1557
  const partDigest = await digestPartBase64(bytes);
1345
- const res = await this.retry(
1346
- options2,
1347
- () => this.fetch(session.session_endpoints.upload_part, {
1558
+ const res = await this.retry(options2, async () => {
1559
+ const r = await this.fetch(session.session_endpoints.upload_part, {
1348
1560
  method: "PUT",
1349
1561
  headers: {
1350
1562
  ...auth,
@@ -1353,27 +1565,41 @@ var FilloClient = class {
1353
1565
  "Content-Range": `bytes ${offset}-${end - 1}/${file.size}`
1354
1566
  },
1355
1567
  body: bytes,
1356
- signal: options2.signal
1357
- })
1358
- );
1568
+ signal: this.uploadSignal(options2)
1569
+ });
1570
+ if (!r.ok && isRetryableStatus(r.status))
1571
+ throw new FilloError(`Box part failed: ${r.status}`, r.status);
1572
+ return r;
1573
+ });
1359
1574
  if (!res.ok) throw new FilloError(`Box part failed: ${res.status}`, res.status);
1360
1575
  parts.push((await res.json()).part);
1361
1576
  offset = end;
1362
1577
  report(offset);
1363
1578
  }
1364
- const commitRes = await this.retry(
1365
- options2,
1366
- () => this.fetch(session.session_endpoints.commit, {
1579
+ const commitRes = await this.retry(options2, async () => {
1580
+ const r = await this.fetch(session.session_endpoints.commit, {
1367
1581
  method: "POST",
1368
1582
  headers: { ...auth, "Content-Type": "application/json", Digest: `sha=${bytesToBase64(whole.digest())}` },
1369
1583
  body: JSON.stringify({ parts }),
1370
- signal: options2.signal
1371
- })
1372
- );
1584
+ signal: this.uploadSignal(options2)
1585
+ });
1586
+ if (!r.ok && isRetryableStatus(r.status))
1587
+ throw new FilloError(`Box commit failed: ${r.status}`, r.status);
1588
+ return r;
1589
+ });
1373
1590
  if (!commitRes.ok) throw new FilloError(`Box commit failed: ${commitRes.status}`, commitRes.status);
1374
1591
  const committed = await commitRes.json();
1375
1592
  return { providerFileId: committed.entries?.[0]?.id };
1376
1593
  }
1594
+ /**
1595
+ * Caller signal (if any) combined with a fresh per-request timeout. Created per
1596
+ * fetch so each retry attempt gets its own deadline; the caller's own abort
1597
+ * still drives the resumable-upload cancel semantics.
1598
+ */
1599
+ uploadSignal(options2) {
1600
+ const timeout = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
1601
+ return options2.signal ? AbortSignal.any([options2.signal, timeout]) : timeout;
1602
+ }
1377
1603
  /** Retry a request with exponential backoff; never retries an aborted upload. */
1378
1604
  async retry(options2, fn) {
1379
1605
  let attempt = 0;
@@ -1398,10 +1624,12 @@ var FilloClient = class {
1398
1624
  let offset = session.uploadedBytes;
1399
1625
  const report = makeReporter(file.size, options2);
1400
1626
  report(offset);
1627
+ let stalls = 0;
1401
1628
  while (offset < file.size) {
1402
1629
  options2.signal?.throwIfAborted();
1403
- const end = Math.min(offset + session.chunkSize, file.size);
1404
- const chunk = file.slice(offset, end);
1630
+ const startedAt = offset;
1631
+ let end = Math.min(offset + session.chunkSize, file.size);
1632
+ let chunk = file.slice(offset, end);
1405
1633
  let attempt = 0;
1406
1634
  for (; ; ) {
1407
1635
  try {
@@ -1411,7 +1639,7 @@ var FilloClient = class {
1411
1639
  "Content-Range": `bytes ${offset}-${end - 1}/${file.size}`
1412
1640
  },
1413
1641
  body: chunk,
1414
- signal: options2.signal
1642
+ signal: this.uploadSignal(options2)
1415
1643
  });
1416
1644
  if (res.status === 308) {
1417
1645
  const range = res.headers.get("Range") ?? res.headers.get("range");
@@ -1433,9 +1661,19 @@ var FilloClient = class {
1433
1661
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
1434
1662
  const fresh = await this.getUploadSession(session.id, session.token);
1435
1663
  offset = fresh.uploadedBytes;
1664
+ if (offset >= file.size) break;
1665
+ end = Math.min(offset + session.chunkSize, file.size);
1666
+ chunk = file.slice(offset, end);
1436
1667
  }
1437
1668
  }
1438
1669
  report(offset);
1670
+ if (offset <= startedAt) {
1671
+ if (++stalls >= MAX_CHUNK_RETRIES) {
1672
+ throw new FilloError("Drive upload stalled \u2014 the storage endpoint stopped accepting progress", 0);
1673
+ }
1674
+ } else {
1675
+ stalls = 0;
1676
+ }
1439
1677
  }
1440
1678
  }
1441
1679
  /**
@@ -1452,7 +1690,7 @@ var FilloClient = class {
1452
1690
  method: "PUT",
1453
1691
  headers: { "Content-Type": file.type || "application/octet-stream" },
1454
1692
  body: file,
1455
- signal: options2.signal
1693
+ signal: this.uploadSignal(options2)
1456
1694
  });
1457
1695
  if (!res.ok) throw new FilloError(`S3 upload failed: ${res.status}`, res.status);
1458
1696
  report(file.size);
@@ -1567,6 +1805,7 @@ function createFormController(options2) {
1567
1805
  const uploadingFields = /* @__PURE__ */ new Set();
1568
1806
  const listeners = /* @__PURE__ */ new Set();
1569
1807
  const mountedAt = Date.now();
1808
+ const idempotencyKey = randomSubmissionKey();
1570
1809
  let sessionId = null;
1571
1810
  let sessionStarted = false;
1572
1811
  function computeSnapshot() {
@@ -1659,7 +1898,8 @@ function createFormController(options2) {
1659
1898
  return;
1660
1899
  }
1661
1900
  if (client && formId) {
1662
- const submissionKey = form.settings.submissionLimit === "once_per_visitor" ? ensureSubmissionKey(formId) : void 0;
1901
+ const visitorKey = form.settings.submissionLimit === "once_per_visitor" ? ensureSubmissionKey(formId) : void 0;
1902
+ const submissionKey = visitorKey ?? idempotencyKey;
1663
1903
  status = "submitting";
1664
1904
  notify();
1665
1905
  let res;
@@ -1667,7 +1907,7 @@ function createFormController(options2) {
1667
1907
  res = await client.submit(formId, result.data, {
1668
1908
  hp: options2.getHoneypot?.() ?? "",
1669
1909
  elapsedMs: Date.now() - mountedAt,
1670
- surface: options2.surface ?? "default",
1910
+ surface: options2.surface ?? "headless",
1671
1911
  submissionKey
1672
1912
  });
1673
1913
  } catch (err) {
@@ -1682,7 +1922,7 @@ function createFormController(options2) {
1682
1922
  return;
1683
1923
  }
1684
1924
  if (sessionId) client.reportProgress(sessionId, { completed: true });
1685
- if (submissionKey) markSubmitted(formId, res.responseId);
1925
+ if (visitorKey) markSubmitted(formId, res.responseId);
1686
1926
  status = "submitted";
1687
1927
  notify();
1688
1928
  emitSubmitted(res.responseId, result.data);
@@ -1765,6 +2005,12 @@ function syncCodeForm(client, form) {
1765
2005
  }
1766
2006
 
1767
2007
  // src/prefill.ts
2008
+ function parseDecimal(raw) {
2009
+ const s = raw.trim();
2010
+ if (!/^-?\d*\.?\d+$/.test(s)) return null;
2011
+ const n = Number(s);
2012
+ return Number.isFinite(n) ? n : null;
2013
+ }
1768
2014
  function prefillFromParams(form, params) {
1769
2015
  const data = {};
1770
2016
  for (const field of allFields(form)) {
@@ -1772,23 +2018,24 @@ function prefillFromParams(form, params) {
1772
2018
  if (raw === void 0 || raw === "") continue;
1773
2019
  switch (field.kind) {
1774
2020
  case "number": {
1775
- const n = Number(raw);
1776
- if (Number.isNaN(n)) break;
2021
+ const n = parseDecimal(raw);
2022
+ if (n === null) break;
1777
2023
  if (field.min !== void 0 && n < field.min) break;
1778
2024
  if (field.max !== void 0 && n > field.max) break;
1779
2025
  data[field.id] = n;
1780
2026
  break;
1781
2027
  }
1782
2028
  case "rating": {
1783
- const n = Number(raw);
1784
- if (Number.isInteger(n) && n >= 1 && n <= (field.max ?? 5)) data[field.id] = n;
2029
+ const n = parseDecimal(raw);
2030
+ if (n !== null && Number.isInteger(n) && n >= 1 && n <= (field.max ?? 5))
2031
+ data[field.id] = n;
1785
2032
  break;
1786
2033
  }
1787
2034
  case "linear_scale": {
1788
- const n = Number(raw);
2035
+ const n = parseDecimal(raw);
1789
2036
  const min = field.min ?? 1;
1790
2037
  const max = field.max ?? 10;
1791
- if (Number.isInteger(n) && n >= min && n <= max) data[field.id] = n;
2038
+ if (n !== null && Number.isInteger(n) && n >= min && n <= max) data[field.id] = n;
1792
2039
  break;
1793
2040
  }
1794
2041
  case "checkbox":
@@ -1861,6 +2108,7 @@ export {
1861
2108
  contentHash,
1862
2109
  countryByDialCode,
1863
2110
  countryByIso,
2111
+ countryByTimeZone,
1864
2112
  createBlock,
1865
2113
  createClient,
1866
2114
  createEmptyForm,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
5
5
  "license": "MIT",
6
6
  "keywords": [