@usefillo/core 0.3.1 → 0.4.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/README.md CHANGED
@@ -15,7 +15,7 @@ npm i @usefillo/core
15
15
  ```ts
16
16
  import { createClient, defineForm, validateResponse, visibleBlocks } from "@usefillo/core";
17
17
 
18
- const client = createClient({ key: "pk_live_…" });
18
+ const client = createClient({ key: "pk_live_…" }); // for syncing code-defined forms
19
19
 
20
20
  const form = defineForm({
21
21
  id: "cust-feedback",
@@ -26,6 +26,10 @@ const form = defineForm({
26
26
  });
27
27
  ```
28
28
 
29
+ Published renderer embeds can fetch by `formId` without a key. Use a client object for custom API origins, uploads, or submissions from your own renderer; add a publishable key only when syncing `defineForm()` schemas from code.
30
+
31
+ Schema settings include `submitMode: "auto"` for one-tap forms that should submit after a complete discrete answer instead of rendering the first submit button, and `submissionLimit: "once_per_visitor"` for browser-scoped duplicate prevention. Themes include `colorScheme: "light" | "dark" | "auto"` for renderer defaults.
32
+
29
33
  Key exports: `createClient` / `FilloClient`, `defineForm`, `validateResponse`, `validateField`, `visibleBlocks`, and the schema types (`FormSchema`, `Field`, `FieldKind`, `FormTheme`, `ResponseData`, …).
30
34
 
31
35
  ## Links
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ type BlockKind = FieldKind | ContentKind;
14
14
  interface SelectOption {
15
15
  id: string;
16
16
  label: string;
17
+ /** Optional visual hint for compact binary choices. */
18
+ icon?: "thumbs_up" | "thumbs_down";
17
19
  }
18
20
  interface BaseBlock {
19
21
  /** Stable id — for fields this is the key responses are stored under. */
@@ -30,9 +32,18 @@ interface BaseField extends BaseBlock {
30
32
  placeholder?: string;
31
33
  }
32
34
  interface TextField extends BaseField {
33
- kind: "short_text" | "long_text" | "email" | "url" | "phone";
35
+ kind: "short_text" | "long_text" | "email" | "url";
34
36
  maxLength?: number;
35
37
  }
38
+ interface PhoneField extends BaseField {
39
+ kind: "phone";
40
+ /**
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.
44
+ */
45
+ defaultCountry?: string;
46
+ }
36
47
  interface NumberField extends BaseField {
37
48
  kind: "number";
38
49
  min?: number;
@@ -48,6 +59,8 @@ interface ChoiceField extends BaseField {
48
59
  }
49
60
  interface CheckboxField extends BaseField {
50
61
  kind: "checkbox";
62
+ /** Default checkbox or a switch-style toggle. */
63
+ appearance?: "checkbox" | "toggle";
51
64
  }
52
65
  interface RatingField extends BaseField {
53
66
  kind: "rating";
@@ -113,7 +126,7 @@ interface CustomField extends BaseField {
113
126
  /** Arbitrary options handed to your component. */
114
127
  config?: Record<string, unknown>;
115
128
  }
116
- type Field = TextField | NumberField | ChoiceField | CheckboxField | RatingField | LinearScaleField | RankingField | MatrixField | SignatureField | DateField | FileUploadField | HiddenField | CustomField;
129
+ type Field = TextField | PhoneField | NumberField | ChoiceField | CheckboxField | RatingField | LinearScaleField | RankingField | MatrixField | SignatureField | DateField | FileUploadField | HiddenField | CustomField;
117
130
  interface HeadingBlock extends BaseBlock {
118
131
  kind: "heading";
119
132
  text: string;
@@ -135,11 +148,24 @@ interface FormPage {
135
148
  blocks: Block[];
136
149
  }
137
150
  interface FormSettings {
151
+ /**
152
+ * Default "button": respondents submit with the footer button. "auto" hides
153
+ * the final-page submit button in default renderers and submits after a
154
+ * single discrete answer, e.g. a thumbs up/down article rating.
155
+ */
156
+ submitMode?: "button" | "auto";
138
157
  submitLabel?: string;
139
158
  successTitle?: string;
140
159
  successMessage?: string;
141
160
  redirectUrl?: string;
142
161
  showProgress?: boolean;
162
+ /**
163
+ * Default "multiple": a browser can submit as often as it wants. Use
164
+ * "once_per_visitor" for tiny docs/article feedback where the SDK should
165
+ * remember this browser has already answered and send a server-side
166
+ * de-duplication key with the response.
167
+ */
168
+ submissionLimit?: "multiple" | "once_per_visitor";
143
169
  /** Notify this address on every submission. */
144
170
  notifyEmail?: string;
145
171
  /** Send respondents a receipt (to the first answered email field). */
@@ -153,6 +179,8 @@ interface FormSchema {
153
179
  settings: FormSettings;
154
180
  }
155
181
  interface FormTheme {
182
+ /** Force default renderer colors, or leave unset/"auto" to follow the system. */
183
+ colorScheme?: "light" | "dark" | "auto";
156
184
  /** Accent for buttons, focus rings, selection. */
157
185
  primary?: string;
158
186
  background?: string;
@@ -161,10 +189,15 @@ interface FormTheme {
161
189
  radius?: string;
162
190
  fontFamily?: string;
163
191
  }
164
- /** Workspace branding state, resolved by the server from the plan. */
192
+ /** Branding the server tells the SDK to render. Server-owned so the wording /
193
+ * link can change centrally without a new SDK release. */
165
194
  interface FormBranding {
166
- /** Show the "Powered by Fillo" badge. True on free plans; paid removes it. */
195
+ /** Show the "Powered by Fillo" badge. */
167
196
  poweredBy: boolean;
197
+ /** Badge text — the SDK falls back to "Powered by Fillo" if absent. */
198
+ label?: string;
199
+ /** Badge link — the SDK falls back to https://fillo.so if absent. */
200
+ href?: string;
168
201
  }
169
202
  /** A completed upload, referenced from response data. */
170
203
  interface FileValue {
@@ -265,6 +298,8 @@ interface ValidationResult {
265
298
  */
266
299
  declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
267
300
 
301
+ declare const FILLO_SCHEMA_VERSION: 1;
302
+ declare const FILLO_SDK_VERSION = "0.4.0";
268
303
  interface SchemaValidationResult {
269
304
  ok: boolean;
270
305
  /** Present when ok — a normalized, structurally-valid schema. */
@@ -272,8 +307,11 @@ interface SchemaValidationResult {
272
307
  /** Present when !ok — a short human-readable reason. */
273
308
  error?: string;
274
309
  }
310
+ /** Normalize untrusted input into a renderer-safe FormSchema. */
311
+ declare function normalizeFormSchema(input: unknown): SchemaValidationResult;
275
312
  /** Validate an untrusted object as a FormSchema. Also enforces unique field ids. */
276
313
  declare function validateFormSchema(input: unknown): SchemaValidationResult;
314
+ declare function normalizeFormTheme(input: unknown): FormTheme | null;
277
315
 
278
316
  /**
279
317
  * Canonical plain-text rendering of an answer — the single source of truth for
@@ -286,6 +324,62 @@ declare function validateFormSchema(input: unknown): SchemaValidationResult;
286
324
  */
287
325
  declare function formatAnswer(field: Field, value: FieldValue): string;
288
326
 
327
+ /**
328
+ * Lightweight, dependency-free phone metadata + helpers powering the SDK's
329
+ * phone field. Deliberately small: it carries enough to render a country
330
+ * picker, format as-you-type, and do a *possible-length* check. Authoritative
331
+ * validation + canonical normalization happen server-side with full
332
+ * libphonenumber data (bundle size is irrelevant there), so this stays lean and
333
+ * the stored value is always E.164.
334
+ */
335
+ interface PhoneCountry {
336
+ /** ISO 3166-1 alpha-2, uppercase ("US"). Also drives the flag emoji. */
337
+ iso2: string;
338
+ /** Display name ("United States"). */
339
+ name: string;
340
+ /** Country calling code without the "+" ("1", "44", "33"). */
341
+ dialCode: string;
342
+ /** Valid national-number lengths in digits. Empty = accept a generic range. */
343
+ lengths: number[];
344
+ /** Digit group sizes for display formatting, e.g. [3,3,4] → "415 555 0123". */
345
+ groups: number[];
346
+ /** Example national number (digits only) for the input placeholder. */
347
+ example: string;
348
+ }
349
+ /** Curated country metadata, ordered for the picker (broad coverage). */
350
+ declare const PHONE_COUNTRIES: PhoneCountry[];
351
+ /** Flag emoji from an ISO-3166 alpha-2 code (regional-indicator letters). */
352
+ declare function flagEmoji(iso2: string): string;
353
+ declare function countryByIso(iso2: string | undefined): PhoneCountry | undefined;
354
+ /** Country whose dial code is the longest prefix of these digits (no "+"). */
355
+ declare function countryByDialCode(digits: string): PhoneCountry | undefined;
356
+ /** Keep only 0-9 (and a leading "+") from arbitrary input. */
357
+ declare function digitsOnly(input: string): string;
358
+ /** Group national digits per the country's format (cosmetic; generic = 3s). */
359
+ declare function formatNational(country: PhoneCountry | undefined, national: string): string;
360
+ /** Assemble the canonical E.164 value from a country + national digits. */
361
+ declare function toE164(country: PhoneCountry, national: string): string;
362
+ interface ParsedPhone {
363
+ /** Best-guess country, if the dial code matched one we know. */
364
+ country?: PhoneCountry;
365
+ /** National significant digits (dial code stripped). */
366
+ national: string;
367
+ /** Canonical E.164 ("+…"), or "" if there were no digits. */
368
+ e164: string;
369
+ }
370
+ /**
371
+ * Parse a stored/typed value into country + national + E.164. Accepts E.164
372
+ * ("+4915123456789"), a bare international number, or — with `fallback` — a
373
+ * national number typed without a dial code.
374
+ */
375
+ declare function parsePhone(value: string, fallback?: PhoneCountry): ParsedPhone;
376
+ /**
377
+ * Cheap "could this be a real number?" check used for inline feedback. Length
378
+ * only — never claims more than it knows; the server does authoritative
379
+ * validation. `country` (when known) tightens the accepted lengths.
380
+ */
381
+ declare function isPossiblePhone(value: string): boolean;
382
+
289
383
  /** Display metadata for every block kind — drives the builder palette. */
290
384
  declare const BLOCK_KIND_META: Record<BlockKind, {
291
385
  label: string;
@@ -358,9 +452,15 @@ interface FilloClientOptions {
358
452
  interface PublishedForm {
359
453
  id: string;
360
454
  slug: string;
455
+ /** Wire contract version for server-provided schema data. */
456
+ schemaVersion?: number;
457
+ /** Lowest SDK version the server expects for this form payload. */
458
+ minSdkVersion?: string;
459
+ /** Data-only feature flags exposed by the server. */
460
+ capabilities?: string[];
361
461
  schema: FormSchema;
362
462
  theme: FormTheme | null;
363
- /** True when the form has stopped accepting responses (closed/limit reached). */
463
+ /** True when the server says the form cannot accept responses. */
364
464
  closed?: boolean;
365
465
  /** Workspace branding state — absent means show the badge (default). */
366
466
  branding?: FormBranding;
@@ -368,6 +468,8 @@ interface PublishedForm {
368
468
  interface SubmitResult {
369
469
  ok: boolean;
370
470
  responseId?: string;
471
+ /** True when the API accepted the request as an already-recorded visitor response. */
472
+ duplicate?: boolean;
371
473
  /** fieldId -> message when the server rejects the submission. */
372
474
  errors?: Record<string, string>;
373
475
  }
@@ -379,6 +481,11 @@ interface SubmitMeta {
379
481
  elapsedMs?: number;
380
482
  /** Embedding surface; "headless" submissions require a paid plan (server-enforced). */
381
483
  surface?: "default" | "headless";
484
+ /**
485
+ * Browser-scoped de-duplication key sent when a form opts into
486
+ * settings.submissionLimit = "once_per_visitor".
487
+ */
488
+ submissionKey?: string;
382
489
  }
383
490
  interface UploadProgress {
384
491
  uploadedBytes: number;
@@ -476,7 +583,8 @@ interface ProvisionWorkspaceResult {
476
583
  organizationId: string;
477
584
  /** A claim link was emailed here so the owner can take ownership later. */
478
585
  claim: {
479
- email: string;
586
+ url: string | null;
587
+ email: string | null;
480
588
  sent: boolean;
481
589
  };
482
590
  /** Caps applied until the workspace is claimed. */
@@ -521,9 +629,9 @@ interface FormControllerOptions {
521
629
  /** Honeypot value, supplied by your renderer; must be empty for a human. */
522
630
  getHoneypot?: () => string;
523
631
  /**
524
- * Let page navigation (next) move forward without validating the current page.
525
- * For previews/builders where you want to flip through pages without filling
526
- * the form in. Submission still validates.
632
+ * @internal Let preview page navigation move forward without validating the
633
+ * current page. Submission still validates, and renderers must not use this
634
+ * option to change visitor-facing behavior such as branding.
527
635
  */
528
636
  skipValidation?: boolean;
529
637
  /**
@@ -657,4 +765,4 @@ declare class Sha1 {
657
765
  }
658
766
  declare const sha1Base64: (bytes: Uint8Array) => string;
659
767
 
660
- 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, 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, type ParagraphBlock, 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, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, formatAnswer, isBlockVisible, isCodeForm, isField, pipeBlock, prefillFromParams, provisionWorkspace, resolveText, sha1Base64, syncCodeForm, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
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 };
package/dist/index.js CHANGED
@@ -53,6 +53,152 @@ function visibleFields(form, data) {
53
53
 
54
54
  // src/validation.ts
55
55
  import { z } from "zod";
56
+
57
+ // src/phone.ts
58
+ var C = (iso2, name, dialCode, lengths = [], groups = [], example = "") => ({ iso2, name, dialCode, lengths, groups, example });
59
+ var PHONE_COUNTRIES = [
60
+ C("US", "United States", "1", [10], [3, 3, 4], "2015550123"),
61
+ C("CA", "Canada", "1", [10], [3, 3, 4], "5145550123"),
62
+ C("GB", "United Kingdom", "44", [10], [4, 6], "7400123456"),
63
+ C("AU", "Australia", "61", [9], [3, 3, 3], "412345678"),
64
+ C("DE", "Germany", "49", [10, 11], [4, 7], "15123456789"),
65
+ C("FR", "France", "33", [9], [1, 2, 2, 2, 2], "612345678"),
66
+ C("IN", "India", "91", [10], [5, 5], "8123456789"),
67
+ C("DK", "Denmark", "45", [8], [2, 2, 2, 2], "32123456"),
68
+ C("SE", "Sweden", "46", [9], [2, 3, 4], "701234567"),
69
+ C("NO", "Norway", "47", [8], [3, 2, 3], "40612345"),
70
+ C("FI", "Finland", "358", [9, 10], [2, 3, 4], "412345678"),
71
+ C("NL", "Netherlands", "31", [9], [1, 4, 4], "612345678"),
72
+ C("BE", "Belgium", "32", [9], [3, 2, 2, 2], "470123456"),
73
+ C("ES", "Spain", "34", [9], [3, 3, 3], "612345678"),
74
+ C("IT", "Italy", "39", [9, 10], [3, 3, 4], "3123456789"),
75
+ C("PT", "Portugal", "351", [9], [3, 3, 3], "912345678"),
76
+ C("IE", "Ireland", "353", [9], [2, 3, 4], "851234567"),
77
+ C("CH", "Switzerland", "41", [9], [2, 3, 2, 2], "781234567"),
78
+ C("AT", "Austria", "43", [10, 11], [3, 7], "6641234567"),
79
+ C("PL", "Poland", "48", [9], [3, 3, 3], "512345678"),
80
+ C("CZ", "Czechia", "420", [9], [3, 3, 3], "601123456"),
81
+ C("GR", "Greece", "30", [10], [3, 3, 4], "6912345678"),
82
+ C("RO", "Romania", "40", [9], [3, 3, 3], "712345678"),
83
+ C("HU", "Hungary", "36", [9], [2, 3, 4], "201234567"),
84
+ C("UA", "Ukraine", "380", [9], [2, 3, 2, 2], "501234567"),
85
+ C("RU", "Russia", "7", [10], [3, 3, 2, 2], "9123456789"),
86
+ C("TR", "Turkey", "90", [10], [3, 3, 2, 2], "5012345678"),
87
+ C("IL", "Israel", "972", [9], [2, 3, 4], "501234567"),
88
+ C("AE", "United Arab Emirates", "971", [9], [2, 3, 4], "501234567"),
89
+ C("SA", "Saudi Arabia", "966", [9], [2, 3, 4], "512345678"),
90
+ C("ZA", "South Africa", "27", [9], [2, 3, 4], "711234567"),
91
+ C("NG", "Nigeria", "234", [10], [3, 3, 4], "8021234567"),
92
+ C("KE", "Kenya", "254", [9], [3, 3, 3], "712123456"),
93
+ C("EG", "Egypt", "20", [10], [3, 3, 4], "1001234567"),
94
+ C("BR", "Brazil", "55", [10, 11], [2, 5, 4], "11961234567"),
95
+ C("MX", "Mexico", "52", [10], [3, 3, 4], "2221234567"),
96
+ C("AR", "Argentina", "54", [10], [4, 6], "1123456789"),
97
+ C("CL", "Chile", "56", [9], [1, 4, 4], "221234567"),
98
+ C("CO", "Colombia", "57", [10], [3, 3, 4], "3211234567"),
99
+ C("PE", "Peru", "51", [9], [3, 3, 3], "912345678"),
100
+ C("CN", "China", "86", [11], [3, 4, 4], "13123456789"),
101
+ C("JP", "Japan", "81", [10], [2, 4, 4], "9012345678"),
102
+ C("KR", "South Korea", "82", [9, 10], [2, 4, 4], "1023456789"),
103
+ C("SG", "Singapore", "65", [8], [4, 4], "81234567"),
104
+ C("HK", "Hong Kong", "852", [8], [4, 4], "51234567"),
105
+ C("TW", "Taiwan", "886", [9], [3, 3, 3], "912345678"),
106
+ C("MY", "Malaysia", "60", [9, 10], [2, 3, 4], "123456789"),
107
+ C("ID", "Indonesia", "62", [10, 11], [3, 4, 4], "81234567890"),
108
+ C("TH", "Thailand", "66", [9], [2, 3, 4], "812345678"),
109
+ C("PH", "Philippines", "63", [10], [3, 3, 4], "9171234567"),
110
+ C("VN", "Vietnam", "84", [9], [3, 3, 3], "912345678"),
111
+ C("PK", "Pakistan", "92", [10], [3, 3, 4], "3012345678"),
112
+ C("BD", "Bangladesh", "880", [10], [2, 4, 4], "1812345678"),
113
+ C("NZ", "New Zealand", "64", [9, 10], [2, 3, 4], "211234567"),
114
+ C("SK", "Slovakia", "421", [9], [3, 3, 3], "912123456"),
115
+ C("BG", "Bulgaria", "359", [9], [2, 3, 4], "481234567"),
116
+ C("HR", "Croatia", "385", [8, 9], [2, 3, 4], "921234567"),
117
+ C("RS", "Serbia", "381", [8, 9], [2, 3, 4], "601234567"),
118
+ C("SI", "Slovenia", "386", [8], [2, 3, 3], "31234567"),
119
+ C("LT", "Lithuania", "370", [8], [3, 2, 3], "61234567"),
120
+ C("LV", "Latvia", "371", [8], [2, 3, 3], "21234567"),
121
+ C("EE", "Estonia", "372", [7, 8], [3, 4], "51234567"),
122
+ C("IS", "Iceland", "354", [7], [3, 4], "6111234"),
123
+ C("LU", "Luxembourg", "352", [9], [3, 3, 3], "628123456"),
124
+ C("MA", "Morocco", "212", [9], [3, 3, 3], "650123456"),
125
+ C("CR", "Costa Rica", "506", [8], [4, 4], "83123456"),
126
+ C("EC", "Ecuador", "593", [9], [2, 3, 4], "991234567"),
127
+ C("UY", "Uruguay", "598", [8], [1, 3, 4], "94231234"),
128
+ C("QA", "Qatar", "974", [8], [4, 4], "33123456"),
129
+ C("KW", "Kuwait", "965", [8], [4, 4], "50012345")
130
+ ];
131
+ var byIso = new Map(PHONE_COUNTRIES.map((c) => [c.iso2, c]));
132
+ function flagEmoji(iso2) {
133
+ const cc = iso2.toUpperCase();
134
+ if (!/^[A-Z]{2}$/.test(cc)) return "\u{1F3F3}\uFE0F";
135
+ return String.fromCodePoint(...[...cc].map((ch) => 127462 + ch.charCodeAt(0) - 65));
136
+ }
137
+ function countryByIso(iso2) {
138
+ return iso2 ? byIso.get(iso2.toUpperCase()) : void 0;
139
+ }
140
+ function countryByDialCode(digits) {
141
+ let best;
142
+ for (const c of PHONE_COUNTRIES) {
143
+ if (digits.startsWith(c.dialCode) && (!best || c.dialCode.length > best.dialCode.length)) {
144
+ best = c;
145
+ }
146
+ }
147
+ return best;
148
+ }
149
+ function digitsOnly(input) {
150
+ return input.replace(/[^\d]/g, "");
151
+ }
152
+ function formatNational(country, national) {
153
+ const digits = digitsOnly(national);
154
+ if (!digits) return "";
155
+ const groups = country?.groups?.length ? country.groups : [];
156
+ const out = [];
157
+ let i = 0;
158
+ for (const size of groups) {
159
+ if (i >= digits.length) break;
160
+ out.push(digits.slice(i, i + size));
161
+ i += size;
162
+ }
163
+ while (i < digits.length) {
164
+ out.push(digits.slice(i, i + 3));
165
+ i += 3;
166
+ }
167
+ return out.join(" ");
168
+ }
169
+ function toE164(country, national) {
170
+ const digits = digitsOnly(national);
171
+ return digits ? `+${country.dialCode}${digits}` : "";
172
+ }
173
+ function parsePhone(value, fallback) {
174
+ const trimmed = (value ?? "").trim();
175
+ const hasPlus = trimmed.startsWith("+");
176
+ const digits = digitsOnly(trimmed);
177
+ if (!digits) return { country: fallback, national: "", e164: "" };
178
+ if (hasPlus) {
179
+ const country = countryByDialCode(digits);
180
+ if (country) {
181
+ const national = digits.slice(country.dialCode.length);
182
+ return { country, national, e164: `+${digits}` };
183
+ }
184
+ return { national: digits, e164: `+${digits}` };
185
+ }
186
+ if (fallback) {
187
+ const national = digits.startsWith(fallback.dialCode) ? digits.slice(fallback.dialCode.length) : digits;
188
+ return { country: fallback, national, e164: `+${fallback.dialCode}${national}` };
189
+ }
190
+ return { national: digits, e164: `+${digits}` };
191
+ }
192
+ function isPossiblePhone(value) {
193
+ const { country, national, e164 } = parsePhone(value);
194
+ if (!e164) return false;
195
+ const total = digitsOnly(e164).length;
196
+ if (total < 8 || total > 15) return false;
197
+ if (country?.lengths.length) return country.lengths.includes(national.length);
198
+ return national.length >= 4;
199
+ }
200
+
201
+ // src/validation.ts
56
202
  var fileValueSchema = z.object({
57
203
  fileId: z.string().min(1),
58
204
  name: z.string(),
@@ -99,7 +245,7 @@ function validateField(field, value) {
99
245
  return null;
100
246
  }
101
247
  case "phone": {
102
- if (typeof value !== "string" || !/^[+\d][\d\s\-().]{4,}$/.test(value))
248
+ if (typeof value !== "string" || !isPossiblePhone(value))
103
249
  return "Enter a valid phone number";
104
250
  return null;
105
251
  }
@@ -250,7 +396,7 @@ var FIELD_KINDS = [
250
396
  var CONTENT_KINDS2 = ["heading", "paragraph", "divider"];
251
397
  var ALL_KINDS = [...FIELD_KINDS, ...CONTENT_KINDS2];
252
398
  var idSchema = z2.string().min(1).max(128);
253
- var optionSchema = z2.object({ id: idSchema, label: z2.string().max(1e3) }).passthrough();
399
+ var optionSchema = z2.object({ id: idSchema, label: z2.string().max(1e3), icon: z2.unknown().optional() }).passthrough();
254
400
  var blockSchema = z2.object({
255
401
  id: idSchema,
256
402
  kind: z2.enum(ALL_KINDS),
@@ -271,23 +417,277 @@ var schemaShape = z2.object({
271
417
  pages: z2.array(pageSchema).min(1).max(50),
272
418
  settings: z2.record(z2.unknown()).optional().default({})
273
419
  });
274
- function validateFormSchema(input) {
420
+ var MAX_SCHEMA_VERSION = 1;
421
+ var FILLO_SCHEMA_VERSION = 1;
422
+ var FILLO_SDK_VERSION = "0.4.0";
423
+ function str(value, max, fallback = "") {
424
+ return typeof value === "string" ? value.trim().slice(0, max) : fallback;
425
+ }
426
+ function optionalStr(value, max) {
427
+ const s = str(value, max);
428
+ return s === "" ? void 0 : s;
429
+ }
430
+ function bool(value) {
431
+ return typeof value === "boolean" ? value : void 0;
432
+ }
433
+ function finite(value) {
434
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
435
+ }
436
+ function int(value, min, max, fallback) {
437
+ const n = finite(value);
438
+ if (n === void 0) return fallback;
439
+ return Math.max(min, Math.min(max, Math.round(n)));
440
+ }
441
+ function options(value, max = 200) {
442
+ if (!Array.isArray(value)) return [];
443
+ return value.slice(0, max).flatMap((item) => {
444
+ if (!item || typeof item !== "object") return [];
445
+ const rec = item;
446
+ const id = str(rec.id, 128);
447
+ const label = str(rec.label, 1e3);
448
+ const icon = rec.icon === "thumbs_up" || rec.icon === "thumbs_down" ? rec.icon : void 0;
449
+ return id && label ? [cleanObject({ id, label, icon })] : [];
450
+ });
451
+ }
452
+ function conditions(value) {
453
+ if (!Array.isArray(value)) return void 0;
454
+ const normalized = value.slice(0, 20).flatMap((item) => {
455
+ if (!item || typeof item !== "object") return [];
456
+ const rec = item;
457
+ const op = rec.op;
458
+ if (op !== "eq" && op !== "neq" && op !== "contains" && op !== "gt" && op !== "lt" && op !== "answered" && op !== "not_answered") {
459
+ return [];
460
+ }
461
+ const fieldId = str(rec.fieldId, 128);
462
+ if (!fieldId) return [];
463
+ const raw = rec.value;
464
+ const value2 = typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean" ? raw : void 0;
465
+ return [{ fieldId, op, ...value2 !== void 0 ? { value: value2 } : {} }];
466
+ });
467
+ return normalized.length ? normalized : void 0;
468
+ }
469
+ function baseBlock(rec) {
470
+ return {
471
+ id: str(rec.id, 128),
472
+ visibleIf: conditions(rec.visibleIf)
473
+ };
474
+ }
475
+ function baseField(rec, kind) {
476
+ const base = baseBlock(rec);
477
+ return {
478
+ ...base,
479
+ kind,
480
+ label: str(rec.label, 2e3, "Untitled field"),
481
+ description: optionalStr(rec.description, 5e3),
482
+ required: bool(rec.required),
483
+ placeholder: optionalStr(rec.placeholder, 500)
484
+ };
485
+ }
486
+ function normalizeUrl(value) {
487
+ const raw = optionalStr(value, 2e3);
488
+ if (!raw) return void 0;
489
+ try {
490
+ const url = new URL(raw);
491
+ return url.protocol === "http:" || url.protocol === "https:" ? raw : void 0;
492
+ } catch {
493
+ return void 0;
494
+ }
495
+ }
496
+ function normalizeSettings(value) {
497
+ const rec = value && typeof value === "object" ? value : {};
498
+ const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
499
+ const submissionLimit = rec.submissionLimit === "once_per_visitor" || rec.submissionLimit === "multiple" ? rec.submissionLimit : void 0;
500
+ return {
501
+ submitMode,
502
+ submitLabel: optionalStr(rec.submitLabel, 100),
503
+ successTitle: optionalStr(rec.successTitle, 200),
504
+ successMessage: optionalStr(rec.successMessage, 5e3),
505
+ redirectUrl: normalizeUrl(rec.redirectUrl),
506
+ showProgress: bool(rec.showProgress),
507
+ submissionLimit,
508
+ notifyEmail: z2.string().email().max(254).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
509
+ sendReceipt: bool(rec.sendReceipt)
510
+ };
511
+ }
512
+ function cleanObject(obj) {
513
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== void 0));
514
+ }
515
+ function normalizeBlock(input) {
516
+ if (!input || typeof input !== "object") return null;
517
+ const rec = input;
518
+ const kind = rec.kind;
519
+ const base = baseBlock(rec);
520
+ if (!base.id || typeof kind !== "string") return null;
521
+ switch (kind) {
522
+ case "heading":
523
+ return cleanObject({ ...base, kind, text: str(rec.text, 2e3, "Section") });
524
+ case "paragraph":
525
+ return cleanObject({ ...base, kind, text: str(rec.text, 5e3) });
526
+ case "divider":
527
+ return cleanObject({ ...base, kind });
528
+ case "short_text":
529
+ case "email":
530
+ case "url":
531
+ return cleanObject({
532
+ ...baseField(rec, kind),
533
+ maxLength: finite(rec.maxLength) === void 0 ? void 0 : int(rec.maxLength, 1, 2e3, 2e3)
534
+ });
535
+ case "phone": {
536
+ const cc = optionalStr(rec.defaultCountry, 2)?.toUpperCase();
537
+ return cleanObject({
538
+ ...baseField(rec, kind),
539
+ defaultCountry: cc && /^[A-Z]{2}$/.test(cc) ? cc : void 0
540
+ });
541
+ }
542
+ case "long_text":
543
+ return cleanObject({
544
+ ...baseField(rec, kind),
545
+ maxLength: finite(rec.maxLength) === void 0 ? void 0 : int(rec.maxLength, 1, 2e4, 2e4)
546
+ });
547
+ case "number":
548
+ return cleanObject({ ...baseField(rec, kind), min: finite(rec.min), max: finite(rec.max) });
549
+ case "select":
550
+ case "multi_select":
551
+ case "dropdown": {
552
+ const opts = options(rec.options);
553
+ if (opts.length === 0) return null;
554
+ return cleanObject({
555
+ ...baseField(rec, kind),
556
+ options: opts,
557
+ allowOther: bool(rec.allowOther),
558
+ shuffleOptions: bool(rec.shuffleOptions)
559
+ });
560
+ }
561
+ case "checkbox": {
562
+ const appearance = rec.appearance === "toggle" ? "toggle" : void 0;
563
+ return cleanObject({
564
+ ...baseField(rec, kind),
565
+ appearance
566
+ });
567
+ }
568
+ case "signature":
569
+ case "date":
570
+ return cleanObject(baseField(rec, kind));
571
+ case "rating":
572
+ return cleanObject({ ...baseField(rec, kind), max: int(rec.max, 1, 10, 5) });
573
+ case "linear_scale": {
574
+ const min = int(rec.min, 0, 9, 1);
575
+ const max = int(rec.max, min + 1, 10, 10);
576
+ return cleanObject({
577
+ ...baseField(rec, kind),
578
+ min,
579
+ max,
580
+ minLabel: optionalStr(rec.minLabel, 100),
581
+ maxLabel: optionalStr(rec.maxLabel, 100)
582
+ });
583
+ }
584
+ case "ranking": {
585
+ const opts = options(rec.options);
586
+ if (opts.length < 2) return null;
587
+ return cleanObject({ ...baseField(rec, kind), options: opts });
588
+ }
589
+ case "matrix": {
590
+ const rows = options(rec.rows);
591
+ const columns = options(rec.columns);
592
+ if (rows.length === 0 || columns.length === 0) return null;
593
+ return cleanObject({ ...baseField(rec, kind), rows, columns });
594
+ }
595
+ case "hidden":
596
+ return cleanObject({
597
+ ...baseField(rec, kind),
598
+ // Hidden fields render nothing, so a required one with no matching URL
599
+ // param / default would be permanently unsubmittable — never required.
600
+ required: false,
601
+ paramName: optionalStr(rec.paramName, 128),
602
+ defaultValue: optionalStr(rec.defaultValue, 1e3)
603
+ });
604
+ case "file_upload":
605
+ return cleanObject({
606
+ ...baseField(rec, kind),
607
+ maxFiles: int(rec.maxFiles, 1, 20, 1),
608
+ maxFileSizeMb: int(rec.maxFileSizeMb, 1, 5e3, 500),
609
+ accept: Array.isArray(rec.accept) ? rec.accept.flatMap((v) => {
610
+ const item = optionalStr(v, 100);
611
+ return item ? [item] : [];
612
+ }).slice(0, 50) : void 0
613
+ });
614
+ case "custom":
615
+ return cleanObject({
616
+ ...baseField(rec, kind),
617
+ component: str(rec.component, 128),
618
+ config: rec.config && typeof rec.config === "object" && !Array.isArray(rec.config) ? rec.config : void 0
619
+ });
620
+ default:
621
+ return null;
622
+ }
623
+ }
624
+ function normalizeFormSchema(input) {
275
625
  const parsed = schemaShape.safeParse(input);
276
626
  if (!parsed.success) {
277
627
  const first = parsed.error.issues[0];
278
628
  const path = first?.path.join(".");
279
629
  return { ok: false, error: first ? `${path ? `${path}: ` : ""}${first.message}` : "Invalid schema" };
280
630
  }
631
+ if (parsed.data.version > MAX_SCHEMA_VERSION) {
632
+ return { ok: false, error: `Unsupported schema version: ${parsed.data.version}` };
633
+ }
634
+ const pages = parsed.data.pages.map((page) => {
635
+ const blocks = page.blocks.flatMap((raw) => {
636
+ const block = normalizeBlock(raw);
637
+ return block ? [block] : [];
638
+ });
639
+ return cleanObject({
640
+ id: page.id,
641
+ title: optionalStr(page.title, 500),
642
+ blocks
643
+ });
644
+ });
281
645
  const seen = /* @__PURE__ */ new Set();
282
- for (const page of parsed.data.pages) {
646
+ for (const page of pages) {
283
647
  for (const block of page.blocks) {
284
- if (seen.has(block.id)) {
285
- return { ok: false, error: `Duplicate block id: ${block.id}` };
286
- }
648
+ if (seen.has(block.id)) return { ok: false, error: `Duplicate block id: ${block.id}` };
287
649
  seen.add(block.id);
288
650
  }
289
651
  }
290
- return { ok: true, schema: parsed.data };
652
+ if (pages.every((page) => page.blocks.length === 0)) {
653
+ return { ok: false, error: "Schema must contain at least one valid block" };
654
+ }
655
+ const schema = {
656
+ version: FILLO_SCHEMA_VERSION,
657
+ title: str(parsed.data.title, 2e3),
658
+ pages,
659
+ settings: normalizeSettings(parsed.data.settings)
660
+ };
661
+ const description = optionalStr(parsed.data.description, 5e3);
662
+ if (description) schema.description = description;
663
+ return { ok: true, schema };
664
+ }
665
+ function validateFormSchema(input) {
666
+ try {
667
+ return normalizeFormSchema(input);
668
+ } catch (err) {
669
+ return { ok: false, error: err instanceof Error ? err.message : "Invalid schema" };
670
+ }
671
+ }
672
+ function safeCssToken(value, max) {
673
+ const token = optionalStr(value, max);
674
+ if (!token) return void 0;
675
+ if (/[;{}<>]/.test(token) || /url\s*\(/i.test(token)) return void 0;
676
+ return token;
677
+ }
678
+ function normalizeFormTheme(input) {
679
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
680
+ const rec = input;
681
+ const colorScheme = rec.colorScheme === "light" || rec.colorScheme === "dark" || rec.colorScheme === "auto" ? rec.colorScheme : void 0;
682
+ const theme = cleanObject({
683
+ colorScheme,
684
+ primary: safeCssToken(rec.primary, 200),
685
+ background: safeCssToken(rec.background, 200),
686
+ text: safeCssToken(rec.text, 200),
687
+ radius: safeCssToken(rec.radius, 64),
688
+ fontFamily: safeCssToken(rec.fontFamily, 200)
689
+ });
690
+ return Object.keys(theme).length > 0 ? theme : null;
291
691
  }
292
692
 
293
693
  // src/format.ts
@@ -318,11 +718,17 @@ function formatAnswer(field, value) {
318
718
  case "linear_scale":
319
719
  case "number":
320
720
  return typeof value === "number" ? String(value) : String(value);
721
+ case "phone": {
722
+ const { country, national, e164 } = parsePhone(String(value));
723
+ if (!e164) return String(value);
724
+ const grouped = formatNational(country, national);
725
+ const dial = country ? `+${country.dialCode}` : e164.replace(national, "");
726
+ return grouped ? `${dial} ${grouped}` : e164;
727
+ }
321
728
  case "short_text":
322
729
  case "long_text":
323
730
  case "email":
324
731
  case "url":
325
- case "phone":
326
732
  case "date":
327
733
  case "hidden":
328
734
  return String(value);
@@ -337,14 +743,14 @@ function formatAnswer(field, value) {
337
743
  var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
338
744
  var MASK = 256 - 256 % ALPHABET.length;
339
745
  function createId(size = 12) {
746
+ const csprng = globalThis.crypto;
747
+ if (typeof csprng === "undefined" || typeof csprng.getRandomValues !== "function") {
748
+ throw new Error("createId requires Web Crypto (globalThis.crypto.getRandomValues)");
749
+ }
340
750
  let id = "";
341
751
  while (id.length < size) {
342
752
  const bytes = new Uint8Array(size);
343
- if (typeof globalThis.crypto !== "undefined") {
344
- globalThis.crypto.getRandomValues(bytes);
345
- } else {
346
- for (let i = 0; i < size; i++) bytes[i] = Math.floor(Math.random() * 256);
347
- }
753
+ csprng.getRandomValues(bytes);
348
754
  for (let i = 0; i < size && id.length < size; i++) {
349
755
  const b = bytes[i];
350
756
  if (b < MASK) id += ALPHABET[b % ALPHABET.length];
@@ -447,6 +853,8 @@ function createBlock(kind) {
447
853
  return { id, kind, label: "Upload a file", maxFiles: 1, maxFileSizeMb: 500 };
448
854
  case "checkbox":
449
855
  return { id, kind, label: "I agree" };
856
+ case "phone":
857
+ return { id, kind, label: "Phone number" };
450
858
  default:
451
859
  return { id, kind, label: BLOCK_KIND_META[kind].label };
452
860
  }
@@ -457,10 +865,12 @@ function createEmptyForm(title = "Untitled form") {
457
865
  title,
458
866
  pages: [{ id: createId(8), blocks: [] }],
459
867
  settings: {
868
+ submitMode: "button",
460
869
  submitLabel: "Submit",
461
870
  successTitle: "Thanks!",
462
871
  successMessage: "Your response has been recorded.",
463
- showProgress: true
872
+ showProgress: true,
873
+ submissionLimit: "multiple"
464
874
  }
465
875
  };
466
876
  }
@@ -487,7 +897,7 @@ var DRAFT_KINDS = [
487
897
  "paragraph",
488
898
  "divider"
489
899
  ];
490
- function str(value, max) {
900
+ function str2(value, max) {
491
901
  return typeof value === "string" ? value.trim().slice(0, max) : "";
492
902
  }
493
903
  function clampInt(value, lo, hi, fallback) {
@@ -496,7 +906,7 @@ function clampInt(value, lo, hi, fallback) {
496
906
  return n < lo ? lo : n > hi ? hi : n;
497
907
  }
498
908
  function toOptions(labels, fallback) {
499
- const cleaned = (Array.isArray(labels) ? labels : []).map((l) => str(l, 200)).filter(Boolean);
909
+ const cleaned = (Array.isArray(labels) ? labels : []).map((l) => str2(l, 200)).filter(Boolean);
500
910
  if (cleaned.length < 2) return fallback;
501
911
  return cleaned.slice(0, 100).map((label) => ({ id: createId(6), label }));
502
912
  }
@@ -504,15 +914,15 @@ function assembleBlock(spec) {
504
914
  const kind = spec.kind;
505
915
  if (!DRAFT_KINDS.includes(kind)) return null;
506
916
  const block = createBlock(kind);
507
- const label = str(spec.label, 500);
917
+ const label = str2(spec.label, 500);
508
918
  if (kind === "heading" || kind === "paragraph") {
509
- block.text = str(spec.text, 2e3) || label || block.text;
919
+ block.text = str2(spec.text, 2e3) || label || block.text;
510
920
  return block;
511
921
  }
512
922
  if (kind === "divider") return block;
513
923
  if (label) block.label = label;
514
924
  if (spec.required) block.required = true;
515
- if (spec.placeholder) block.placeholder = str(spec.placeholder, 200);
925
+ if (spec.placeholder) block.placeholder = str2(spec.placeholder, 200);
516
926
  switch (kind) {
517
927
  case "select":
518
928
  case "multi_select":
@@ -526,8 +936,8 @@ function assembleBlock(spec) {
526
936
  case "linear_scale":
527
937
  block.min = spec.min === 0 ? 0 : 1;
528
938
  block.max = clampInt(spec.max, 2, 10, 10);
529
- if (spec.minLabel) block.minLabel = str(spec.minLabel, 100);
530
- if (spec.maxLabel) block.maxLabel = str(spec.maxLabel, 100);
939
+ if (spec.minLabel) block.minLabel = str2(spec.minLabel, 100);
940
+ if (spec.maxLabel) block.maxLabel = str2(spec.maxLabel, 100);
531
941
  break;
532
942
  case "number":
533
943
  if (typeof spec.min === "number") block.min = spec.min;
@@ -541,12 +951,12 @@ function assembleBlock(spec) {
541
951
  return block;
542
952
  }
543
953
  function assembleForm(spec) {
544
- const form = createEmptyForm(str(spec.title, 200) || "Untitled form");
545
- const description = str(spec.description, 1e3);
954
+ const form = createEmptyForm(str2(spec.title, 200) || "Untitled form");
955
+ const description = str2(spec.description, 1e3);
546
956
  if (description) form.description = description;
547
957
  const pages = (Array.isArray(spec.pages) ? spec.pages : []).slice(0, 20).map((page) => ({
548
958
  id: createId(8),
549
- title: str(page.title, 200) || void 0,
959
+ title: str2(page.title, 200) || void 0,
550
960
  blocks: (Array.isArray(page.fields) ? page.fields : []).slice(0, 100).map(assembleBlock).filter((b) => b !== null)
551
961
  })).filter((page) => page.blocks.length > 0);
552
962
  if (pages.length > 0) form.pages = pages;
@@ -673,8 +1083,31 @@ var FilloError = class extends Error {
673
1083
  status;
674
1084
  };
675
1085
  var MAX_CHUNK_RETRIES = 3;
676
- function makeReporter(totalBytes, options) {
677
- return (uploadedBytes) => options.onProgress?.({
1086
+ function compareVersions(a, b) {
1087
+ const left = a.split(".").map((part) => Number(part) || 0);
1088
+ const right = b.split(".").map((part) => Number(part) || 0);
1089
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
1090
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
1091
+ if (diff !== 0) return diff;
1092
+ }
1093
+ return 0;
1094
+ }
1095
+ function assertPublishedFormCompatible(form) {
1096
+ if (form.schemaVersion !== void 0 && form.schemaVersion !== FILLO_SCHEMA_VERSION) {
1097
+ throw new FilloError(
1098
+ `This form uses schema version ${form.schemaVersion}; this SDK supports version ${FILLO_SCHEMA_VERSION}. Update @usefillo/* to render it.`,
1099
+ 426
1100
+ );
1101
+ }
1102
+ if (form.minSdkVersion && compareVersions(FILLO_SDK_VERSION, form.minSdkVersion) < 0) {
1103
+ throw new FilloError(
1104
+ `This form requires @usefillo/* ${form.minSdkVersion} or newer. Current SDK is ${FILLO_SDK_VERSION}.`,
1105
+ 426
1106
+ );
1107
+ }
1108
+ }
1109
+ function makeReporter(totalBytes, options2) {
1110
+ return (uploadedBytes) => options2.onProgress?.({
678
1111
  uploadedBytes,
679
1112
  totalBytes,
680
1113
  fraction: totalBytes === 0 ? 1 : uploadedBytes / totalBytes
@@ -695,10 +1128,10 @@ var FilloClient = class {
695
1128
  fetch;
696
1129
  /** Publishable workspace key, when configured. */
697
1130
  key;
698
- constructor(options = {}) {
699
- this.baseUrl = options.sameOrigin ? "" : DEFAULT_BASE_URL;
700
- this.key = options.key;
701
- this.fetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
1131
+ constructor(options2 = {}) {
1132
+ this.baseUrl = options2.sameOrigin ? "" : DEFAULT_BASE_URL;
1133
+ this.key = options2.key;
1134
+ this.fetch = options2.fetch ?? ((...args) => globalThis.fetch(...args));
702
1135
  }
703
1136
  url(path) {
704
1137
  return `${this.baseUrl}/api/v1${path}`;
@@ -715,8 +1148,10 @@ var FilloClient = class {
715
1148
  return await res.json();
716
1149
  }
717
1150
  /** Fetch a published form definition by id or slug. */
718
- getForm(idOrSlug) {
719
- return this.json(`/forms/${encodeURIComponent(idOrSlug)}`);
1151
+ async getForm(idOrSlug) {
1152
+ const form = await this.json(`/forms/${encodeURIComponent(idOrSlug)}`);
1153
+ assertPublishedFormCompatible(form);
1154
+ return form;
720
1155
  }
721
1156
  /**
722
1157
  * Upsert a code-defined form into the workspace identified by the client's
@@ -728,9 +1163,18 @@ var FilloClient = class {
728
1163
  new FilloError("createClient needs a `key` (pk_\u2026) to sync code-defined forms", 401)
729
1164
  );
730
1165
  }
1166
+ const normalized = normalizeFormSchema(schema);
1167
+ if (!normalized.ok) {
1168
+ return Promise.reject(new FilloError(`Invalid form schema: ${normalized.error}`, 400));
1169
+ }
731
1170
  return this.json("/forms/sync", {
732
1171
  method: "POST",
733
- body: JSON.stringify({ key: this.key, id: handle, schema, theme })
1172
+ body: JSON.stringify({
1173
+ key: this.key,
1174
+ id: handle,
1175
+ schema: normalized.schema,
1176
+ theme: normalizeFormTheme(theme)
1177
+ })
734
1178
  });
735
1179
  }
736
1180
  /** Submit a response. Returns per-field errors instead of throwing on validation failure. */
@@ -748,7 +1192,7 @@ var FilloClient = class {
748
1192
  throw new FilloError(`Submit failed: ${res.status}`, res.status);
749
1193
  }
750
1194
  const body = await res.json();
751
- return { ok: true, responseId: body.id };
1195
+ return { ok: true, responseId: body.id, duplicate: body.duplicate };
752
1196
  }
753
1197
  /**
754
1198
  * Open a respondent session for funnel analysis. Fire-and-forget: returns the
@@ -800,19 +1244,19 @@ var FilloClient = class {
800
1244
  * sessions — in which case the bytes go straight from this browser to the
801
1245
  * form owner's Drive and never touch the Fillo server.
802
1246
  */
803
- async uploadFile(formId, file, options) {
1247
+ async uploadFile(formId, file, options2) {
804
1248
  const name = file instanceof File ? file.name : "upload.bin";
805
1249
  const mime = file.type || "application/octet-stream";
806
1250
  let session;
807
- if (options.sessionId) {
808
- session = await this.getUploadSession(options.sessionId, options.uploadToken);
1251
+ if (options2.sessionId) {
1252
+ session = await this.getUploadSession(options2.sessionId, options2.uploadToken);
809
1253
  } else {
810
1254
  session = await this.json(
811
1255
  `/forms/${encodeURIComponent(formId)}/uploads`,
812
1256
  {
813
1257
  method: "POST",
814
1258
  body: JSON.stringify({
815
- fieldId: options.fieldId,
1259
+ fieldId: options2.fieldId,
816
1260
  fileName: name,
817
1261
  size: file.size,
818
1262
  mime
@@ -822,11 +1266,11 @@ var FilloClient = class {
822
1266
  }
823
1267
  let completeBody;
824
1268
  if (session.transport?.type === "gdrive") {
825
- await this.driveUploadLoop(session, session.transport.uploadUrl, file, options);
1269
+ await this.driveUploadLoop(session, session.transport.uploadUrl, file, options2);
826
1270
  } else if (session.transport?.type === "s3-put") {
827
- await this.s3PutUpload(session.transport.uploadUrl, file, options);
1271
+ await this.s3PutUpload(session.transport.uploadUrl, file, options2);
828
1272
  } else if (session.transport?.type === "box") {
829
- completeBody = await this.boxUpload(session.transport, file, options);
1273
+ completeBody = await this.boxUpload(session.transport, file, options2);
830
1274
  } else {
831
1275
  throw new FilloError("This form's storage doesn't support uploads \u2014 connect Drive, S3, or Box.");
832
1276
  }
@@ -848,8 +1292,8 @@ var FilloClient = class {
848
1292
  * SHA-1 `Digest`, then commit with the whole-file digest. Bytes go straight to
849
1293
  * Box; we return the new file id for /complete to verify server-side.
850
1294
  */
851
- async boxUpload(transport, file, options) {
852
- const report = makeReporter(file.size, options);
1295
+ async boxUpload(transport, file, options2) {
1296
+ const report = makeReporter(file.size, options2);
853
1297
  report(0);
854
1298
  const auth = { Authorization: `Bearer ${transport.token}` };
855
1299
  if (transport.mode === "simple") {
@@ -860,8 +1304,8 @@ var FilloClient = class {
860
1304
  );
861
1305
  form.append("file", file, transport.fileName);
862
1306
  const res = await this.retry(
863
- options,
864
- () => this.fetch(transport.uploadUrl, { method: "POST", headers: auth, body: form, signal: options.signal })
1307
+ options2,
1308
+ () => this.fetch(transport.uploadUrl, { method: "POST", headers: auth, body: form, signal: options2.signal })
865
1309
  );
866
1310
  if (!res.ok) throw new FilloError(`Box upload failed: ${res.status}`, res.status);
867
1311
  const body = await res.json();
@@ -875,7 +1319,7 @@ var FilloClient = class {
875
1319
  );
876
1320
  }
877
1321
  const sessionRes = await this.retry(
878
- options,
1322
+ options2,
879
1323
  () => this.fetch(transport.sessionUrl, {
880
1324
  method: "POST",
881
1325
  headers: { ...auth, "Content-Type": "application/json" },
@@ -884,7 +1328,7 @@ var FilloClient = class {
884
1328
  file_size: transport.size,
885
1329
  file_name: transport.fileName
886
1330
  }),
887
- signal: options.signal
1331
+ signal: options2.signal
888
1332
  })
889
1333
  );
890
1334
  if (!sessionRes.ok) throw new FilloError(`Box session failed: ${sessionRes.status}`, sessionRes.status);
@@ -893,13 +1337,13 @@ var FilloClient = class {
893
1337
  const parts = [];
894
1338
  let offset = 0;
895
1339
  while (offset < file.size) {
896
- options.signal?.throwIfAborted();
1340
+ options2.signal?.throwIfAborted();
897
1341
  const end = Math.min(offset + session.part_size, file.size);
898
1342
  const bytes = new Uint8Array(await file.slice(offset, end).arrayBuffer());
899
1343
  whole.update(bytes);
900
1344
  const partDigest = await digestPartBase64(bytes);
901
1345
  const res = await this.retry(
902
- options,
1346
+ options2,
903
1347
  () => this.fetch(session.session_endpoints.upload_part, {
904
1348
  method: "PUT",
905
1349
  headers: {
@@ -909,7 +1353,7 @@ var FilloClient = class {
909
1353
  "Content-Range": `bytes ${offset}-${end - 1}/${file.size}`
910
1354
  },
911
1355
  body: bytes,
912
- signal: options.signal
1356
+ signal: options2.signal
913
1357
  })
914
1358
  );
915
1359
  if (!res.ok) throw new FilloError(`Box part failed: ${res.status}`, res.status);
@@ -918,12 +1362,12 @@ var FilloClient = class {
918
1362
  report(offset);
919
1363
  }
920
1364
  const commitRes = await this.retry(
921
- options,
1365
+ options2,
922
1366
  () => this.fetch(session.session_endpoints.commit, {
923
1367
  method: "POST",
924
1368
  headers: { ...auth, "Content-Type": "application/json", Digest: `sha=${bytesToBase64(whole.digest())}` },
925
1369
  body: JSON.stringify({ parts }),
926
- signal: options.signal
1370
+ signal: options2.signal
927
1371
  })
928
1372
  );
929
1373
  if (!commitRes.ok) throw new FilloError(`Box commit failed: ${commitRes.status}`, commitRes.status);
@@ -931,13 +1375,13 @@ var FilloClient = class {
931
1375
  return { providerFileId: committed.entries?.[0]?.id };
932
1376
  }
933
1377
  /** Retry a request with exponential backoff; never retries an aborted upload. */
934
- async retry(options, fn) {
1378
+ async retry(options2, fn) {
935
1379
  let attempt = 0;
936
1380
  for (; ; ) {
937
1381
  try {
938
1382
  return await fn();
939
1383
  } catch (err) {
940
- if (options.signal?.aborted) throw err;
1384
+ if (options2.signal?.aborted) throw err;
941
1385
  attempt += 1;
942
1386
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
943
1387
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
@@ -950,12 +1394,12 @@ var FilloClient = class {
950
1394
  * repeated failure we re-sync through our session endpoint (the server
951
1395
  * queries Drive for the authoritative offset).
952
1396
  */
953
- async driveUploadLoop(session, uploadUrl, file, options) {
1397
+ async driveUploadLoop(session, uploadUrl, file, options2) {
954
1398
  let offset = session.uploadedBytes;
955
- const report = makeReporter(file.size, options);
1399
+ const report = makeReporter(file.size, options2);
956
1400
  report(offset);
957
1401
  while (offset < file.size) {
958
- options.signal?.throwIfAborted();
1402
+ options2.signal?.throwIfAborted();
959
1403
  const end = Math.min(offset + session.chunkSize, file.size);
960
1404
  const chunk = file.slice(offset, end);
961
1405
  let attempt = 0;
@@ -967,7 +1411,7 @@ var FilloClient = class {
967
1411
  "Content-Range": `bytes ${offset}-${end - 1}/${file.size}`
968
1412
  },
969
1413
  body: chunk,
970
- signal: options.signal
1414
+ signal: options2.signal
971
1415
  });
972
1416
  if (res.status === 308) {
973
1417
  const range = res.headers.get("Range") ?? res.headers.get("range");
@@ -983,7 +1427,7 @@ var FilloClient = class {
983
1427
  }
984
1428
  throw new FilloError(`Drive chunk failed: ${res.status}`, res.status);
985
1429
  } catch (err) {
986
- if (options.signal?.aborted) throw err;
1430
+ if (options2.signal?.aborted) throw err;
987
1431
  attempt += 1;
988
1432
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
989
1433
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
@@ -998,8 +1442,8 @@ var FilloClient = class {
998
1442
  * S3-compatible single PUT to a presigned URL — bytes go straight to the
999
1443
  * bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
1000
1444
  */
1001
- async s3PutUpload(uploadUrl, file, options) {
1002
- const report = makeReporter(file.size, options);
1445
+ async s3PutUpload(uploadUrl, file, options2) {
1446
+ const report = makeReporter(file.size, options2);
1003
1447
  report(0);
1004
1448
  let attempt = 0;
1005
1449
  for (; ; ) {
@@ -1008,13 +1452,13 @@ var FilloClient = class {
1008
1452
  method: "PUT",
1009
1453
  headers: { "Content-Type": file.type || "application/octet-stream" },
1010
1454
  body: file,
1011
- signal: options.signal
1455
+ signal: options2.signal
1012
1456
  });
1013
1457
  if (!res.ok) throw new FilloError(`S3 upload failed: ${res.status}`, res.status);
1014
1458
  report(file.size);
1015
1459
  return;
1016
1460
  } catch (err) {
1017
- if (options.signal?.aborted) throw err;
1461
+ if (options2.signal?.aborted) throw err;
1018
1462
  attempt += 1;
1019
1463
  if (attempt >= MAX_CHUNK_RETRIES) throw err;
1020
1464
  await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
@@ -1022,16 +1466,16 @@ var FilloClient = class {
1022
1466
  }
1023
1467
  }
1024
1468
  };
1025
- function createClient(options = {}) {
1026
- return new FilloClient(options);
1469
+ function createClient(options2 = {}) {
1470
+ return new FilloClient(options2);
1027
1471
  }
1028
- async function provisionWorkspace(options) {
1029
- const base = options.sameOrigin ? "" : DEFAULT_BASE_URL;
1030
- const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
1472
+ async function provisionWorkspace(options2) {
1473
+ const base = options2.sameOrigin ? "" : DEFAULT_BASE_URL;
1474
+ const doFetch = options2.fetch ?? ((...args) => globalThis.fetch(...args));
1031
1475
  const res = await doFetch(`${base}/api/v1/workspaces/provision`, {
1032
1476
  method: "POST",
1033
1477
  headers: { "Content-Type": "application/json" },
1034
- body: JSON.stringify({ email: options.email, source: options.source ?? "sdk" })
1478
+ body: JSON.stringify({ email: options2.email, source: options2.source ?? "sdk" })
1035
1479
  });
1036
1480
  if (!res.ok) {
1037
1481
  const body = await res.text().catch(() => "");
@@ -1050,15 +1494,76 @@ function warnNoClient() {
1050
1494
  "[fillo] This form has no `client`, so responses can't be submitted \u2014 it's render-only. Pass a client (createClient) to collect responses in Fillo, or forward them to your own backend with webhooks: https://fillo.so/docs"
1051
1495
  );
1052
1496
  }
1053
- function createFormController(options) {
1054
- const { onChange, onSubmitted } = options;
1055
- let form = options.form;
1056
- let formId = options.formId;
1057
- let client = options.client;
1058
- let data = { ...options.initialData ?? {} };
1497
+ var memorySubmissionKeys = /* @__PURE__ */ new Map();
1498
+ function browserStorage() {
1499
+ try {
1500
+ return globalThis.localStorage ?? null;
1501
+ } catch {
1502
+ return null;
1503
+ }
1504
+ }
1505
+ function submissionStorageKey(formId) {
1506
+ return `fillo:submission:${formId}`;
1507
+ }
1508
+ function randomSubmissionKey() {
1509
+ const crypto = globalThis.crypto;
1510
+ if (crypto?.randomUUID) return crypto.randomUUID();
1511
+ const bytes = new Uint8Array(16);
1512
+ if (crypto?.getRandomValues) {
1513
+ crypto.getRandomValues(bytes);
1514
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1515
+ }
1516
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1517
+ }
1518
+ function readStoredSubmission(formId) {
1519
+ const store = browserStorage();
1520
+ if (!store) return memorySubmissionKeys.get(formId) ?? null;
1521
+ try {
1522
+ const parsed = JSON.parse(store.getItem(submissionStorageKey(formId)) ?? "null");
1523
+ if (parsed && typeof parsed.key === "string") return parsed;
1524
+ } catch {
1525
+ }
1526
+ return null;
1527
+ }
1528
+ function writeStoredSubmission(formId, value) {
1529
+ memorySubmissionKeys.set(formId, value);
1530
+ const store = browserStorage();
1531
+ if (!store) return;
1532
+ try {
1533
+ store.setItem(submissionStorageKey(formId), JSON.stringify(value));
1534
+ } catch {
1535
+ }
1536
+ }
1537
+ function ensureSubmissionKey(formId) {
1538
+ const existing = readStoredSubmission(formId);
1539
+ if (existing?.key) return existing.key;
1540
+ const next = { key: randomSubmissionKey() };
1541
+ writeStoredSubmission(formId, next);
1542
+ return next.key;
1543
+ }
1544
+ function markSubmitted(formId, responseId) {
1545
+ const existing = readStoredSubmission(formId);
1546
+ writeStoredSubmission(formId, {
1547
+ key: existing?.key ?? randomSubmissionKey(),
1548
+ submittedAt: (/* @__PURE__ */ new Date()).toISOString(),
1549
+ responseId
1550
+ });
1551
+ }
1552
+ function hasSubmittedOnce(form, formId) {
1553
+ if (form.settings.submissionLimit !== "once_per_visitor" || !formId) return false;
1554
+ return Boolean(readStoredSubmission(formId)?.submittedAt);
1555
+ }
1556
+ function createFormController(options2) {
1557
+ const { onChange, onSubmitted } = options2;
1558
+ const initial = normalizeFormSchema(options2.form);
1559
+ if (!initial.ok) throw new Error(`Invalid form schema: ${initial.error}`);
1560
+ let form = initial.schema;
1561
+ let formId = options2.formId;
1562
+ let client = options2.client;
1563
+ let data = { ...options2.initialData ?? {} };
1059
1564
  let errors = {};
1060
1565
  let pageIndex = 0;
1061
- let status = "idle";
1566
+ let status = hasSubmittedOnce(form, formId) ? "submitted" : "idle";
1062
1567
  const uploadingFields = /* @__PURE__ */ new Set();
1063
1568
  const listeners = /* @__PURE__ */ new Set();
1064
1569
  const mountedAt = Date.now();
@@ -1124,7 +1629,7 @@ function createFormController(options) {
1124
1629
  function next() {
1125
1630
  const pageCount = form.pages.length;
1126
1631
  const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
1127
- if (!options.skipValidation) {
1632
+ if (!options2.skipValidation) {
1128
1633
  const pageErrors = validatePage(clamped);
1129
1634
  errors = pageErrors;
1130
1635
  if (Object.keys(pageErrors).length > 0) {
@@ -1146,20 +1651,24 @@ function createFormController(options) {
1146
1651
  const result = validateResponse(form, data);
1147
1652
  if (!result.ok) {
1148
1653
  errors = result.errors;
1149
- const firstBad = form.pages.findIndex((p) => p.blocks.some((b) => b.id in result.errors));
1654
+ const firstBad = form.pages.findIndex(
1655
+ (p) => visibleBlocks(p, data).some((b) => b.id in result.errors)
1656
+ );
1150
1657
  if (firstBad >= 0) pageIndex = firstBad;
1151
1658
  notify();
1152
1659
  return;
1153
1660
  }
1154
1661
  if (client && formId) {
1662
+ const submissionKey = form.settings.submissionLimit === "once_per_visitor" ? ensureSubmissionKey(formId) : void 0;
1155
1663
  status = "submitting";
1156
1664
  notify();
1157
1665
  let res;
1158
1666
  try {
1159
1667
  res = await client.submit(formId, result.data, {
1160
- hp: options.getHoneypot?.() ?? "",
1668
+ hp: options2.getHoneypot?.() ?? "",
1161
1669
  elapsedMs: Date.now() - mountedAt,
1162
- surface: options.surface ?? "default"
1670
+ surface: options2.surface ?? "default",
1671
+ submissionKey
1163
1672
  });
1164
1673
  } catch (err) {
1165
1674
  status = "idle";
@@ -1173,12 +1682,13 @@ function createFormController(options) {
1173
1682
  return;
1174
1683
  }
1175
1684
  if (sessionId) client.reportProgress(sessionId, { completed: true });
1685
+ if (submissionKey) markSubmitted(formId, res.responseId);
1176
1686
  status = "submitted";
1177
1687
  notify();
1178
1688
  emitSubmitted(res.responseId, result.data);
1179
1689
  return;
1180
1690
  }
1181
- if (options.skipValidation) {
1691
+ if (options2.skipValidation) {
1182
1692
  status = "submitted";
1183
1693
  notify();
1184
1694
  emitSubmitted(void 0, result.data);
@@ -1205,9 +1715,12 @@ function createFormController(options) {
1205
1715
  if ("formId" in ctx) formId = ctx.formId;
1206
1716
  if ("client" in ctx) client = ctx.client;
1207
1717
  if (ctx.form && ctx.form !== form) {
1208
- form = ctx.form;
1209
- notify();
1718
+ const next2 = normalizeFormSchema(ctx.form);
1719
+ if (!next2.ok) throw new Error(`Invalid form schema: ${next2.error}`);
1720
+ form = next2.schema;
1210
1721
  }
1722
+ if (status === "idle" && hasSubmittedOnce(form, formId)) status = "submitted";
1723
+ notify();
1211
1724
  },
1212
1725
  destroy() {
1213
1726
  listeners.clear();
@@ -1337,28 +1850,41 @@ export {
1337
1850
  BLOCK_KIND_META,
1338
1851
  CONTENT_KINDS,
1339
1852
  DRAFT_KINDS,
1853
+ FILLO_SCHEMA_VERSION,
1854
+ FILLO_SDK_VERSION,
1340
1855
  FilloClient,
1341
1856
  FilloError,
1857
+ PHONE_COUNTRIES,
1342
1858
  Sha1,
1343
1859
  allFields,
1344
1860
  assembleForm,
1345
1861
  contentHash,
1862
+ countryByDialCode,
1863
+ countryByIso,
1346
1864
  createBlock,
1347
1865
  createClient,
1348
1866
  createEmptyForm,
1349
1867
  createFormController,
1350
1868
  createId,
1351
1869
  defineForm,
1870
+ digitsOnly,
1871
+ flagEmoji,
1352
1872
  formatAnswer,
1873
+ formatNational,
1353
1874
  isBlockVisible,
1354
1875
  isCodeForm,
1355
1876
  isField,
1877
+ isPossiblePhone,
1878
+ normalizeFormSchema,
1879
+ normalizeFormTheme,
1880
+ parsePhone,
1356
1881
  pipeBlock,
1357
1882
  prefillFromParams,
1358
1883
  provisionWorkspace,
1359
1884
  resolveText,
1360
1885
  sha1Base64,
1361
1886
  syncCodeForm,
1887
+ toE164,
1362
1888
  validateField,
1363
1889
  validateFormSchema,
1364
1890
  validateResponse,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
5
5
  "license": "MIT",
6
6
  "keywords": [