@usefillo/core 0.3.2 → 0.5.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 +5 -1
- package/dist/index.d.ts +111 -8
- package/dist/index.js +612 -86
- package/package.json +2 -2
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"
|
|
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;
|
|
@@ -270,6 +298,8 @@ interface ValidationResult {
|
|
|
270
298
|
*/
|
|
271
299
|
declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
|
|
272
300
|
|
|
301
|
+
declare const FILLO_SCHEMA_VERSION: 1;
|
|
302
|
+
declare const FILLO_SDK_VERSION = "0.5.0";
|
|
273
303
|
interface SchemaValidationResult {
|
|
274
304
|
ok: boolean;
|
|
275
305
|
/** Present when ok — a normalized, structurally-valid schema. */
|
|
@@ -277,8 +307,11 @@ interface SchemaValidationResult {
|
|
|
277
307
|
/** Present when !ok — a short human-readable reason. */
|
|
278
308
|
error?: string;
|
|
279
309
|
}
|
|
310
|
+
/** Normalize untrusted input into a renderer-safe FormSchema. */
|
|
311
|
+
declare function normalizeFormSchema(input: unknown): SchemaValidationResult;
|
|
280
312
|
/** Validate an untrusted object as a FormSchema. Also enforces unique field ids. */
|
|
281
313
|
declare function validateFormSchema(input: unknown): SchemaValidationResult;
|
|
314
|
+
declare function normalizeFormTheme(input: unknown): FormTheme | null;
|
|
282
315
|
|
|
283
316
|
/**
|
|
284
317
|
* Canonical plain-text rendering of an answer — the single source of truth for
|
|
@@ -291,6 +324,62 @@ declare function validateFormSchema(input: unknown): SchemaValidationResult;
|
|
|
291
324
|
*/
|
|
292
325
|
declare function formatAnswer(field: Field, value: FieldValue): string;
|
|
293
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
|
+
|
|
294
383
|
/** Display metadata for every block kind — drives the builder palette. */
|
|
295
384
|
declare const BLOCK_KIND_META: Record<BlockKind, {
|
|
296
385
|
label: string;
|
|
@@ -363,9 +452,15 @@ interface FilloClientOptions {
|
|
|
363
452
|
interface PublishedForm {
|
|
364
453
|
id: string;
|
|
365
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[];
|
|
366
461
|
schema: FormSchema;
|
|
367
462
|
theme: FormTheme | null;
|
|
368
|
-
/** True when the
|
|
463
|
+
/** True when the server says the form cannot accept responses. */
|
|
369
464
|
closed?: boolean;
|
|
370
465
|
/** Workspace branding state — absent means show the badge (default). */
|
|
371
466
|
branding?: FormBranding;
|
|
@@ -373,6 +468,8 @@ interface PublishedForm {
|
|
|
373
468
|
interface SubmitResult {
|
|
374
469
|
ok: boolean;
|
|
375
470
|
responseId?: string;
|
|
471
|
+
/** True when the API accepted the request as an already-recorded visitor response. */
|
|
472
|
+
duplicate?: boolean;
|
|
376
473
|
/** fieldId -> message when the server rejects the submission. */
|
|
377
474
|
errors?: Record<string, string>;
|
|
378
475
|
}
|
|
@@ -384,6 +481,11 @@ interface SubmitMeta {
|
|
|
384
481
|
elapsedMs?: number;
|
|
385
482
|
/** Embedding surface; "headless" submissions require a paid plan (server-enforced). */
|
|
386
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;
|
|
387
489
|
}
|
|
388
490
|
interface UploadProgress {
|
|
389
491
|
uploadedBytes: number;
|
|
@@ -481,7 +583,8 @@ interface ProvisionWorkspaceResult {
|
|
|
481
583
|
organizationId: string;
|
|
482
584
|
/** A claim link was emailed here so the owner can take ownership later. */
|
|
483
585
|
claim: {
|
|
484
|
-
|
|
586
|
+
url: string | null;
|
|
587
|
+
email: string | null;
|
|
485
588
|
sent: boolean;
|
|
486
589
|
};
|
|
487
590
|
/** Caps applied until the workspace is claimed. */
|
|
@@ -526,9 +629,9 @@ interface FormControllerOptions {
|
|
|
526
629
|
/** Honeypot value, supplied by your renderer; must be empty for a human. */
|
|
527
630
|
getHoneypot?: () => string;
|
|
528
631
|
/**
|
|
529
|
-
* Let page navigation
|
|
530
|
-
*
|
|
531
|
-
*
|
|
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.
|
|
532
635
|
*/
|
|
533
636
|
skipValidation?: boolean;
|
|
534
637
|
/**
|
|
@@ -662,4 +765,4 @@ declare class Sha1 {
|
|
|
662
765
|
}
|
|
663
766
|
declare const sha1Base64: (bytes: Uint8Array) => string;
|
|
664
767
|
|
|
665
|
-
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(),
|
|
@@ -83,7 +229,7 @@ function validateField(field, value) {
|
|
|
83
229
|
return null;
|
|
84
230
|
}
|
|
85
231
|
case "email": {
|
|
86
|
-
const r = z.
|
|
232
|
+
const r = z.email().safeParse(value);
|
|
87
233
|
return r.success ? null : "Enter a valid email address";
|
|
88
234
|
}
|
|
89
235
|
case "url": {
|
|
@@ -99,7 +245,7 @@ function validateField(field, value) {
|
|
|
99
245
|
return null;
|
|
100
246
|
}
|
|
101
247
|
case "phone": {
|
|
102
|
-
if (typeof value !== "string" ||
|
|
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),
|
|
@@ -269,25 +415,279 @@ var schemaShape = z2.object({
|
|
|
269
415
|
title: z2.string().max(2e3).optional().default(""),
|
|
270
416
|
description: z2.string().max(5e3).optional(),
|
|
271
417
|
pages: z2.array(pageSchema).min(1).max(50),
|
|
272
|
-
settings: z2.record(z2.unknown()).optional().default({})
|
|
418
|
+
settings: z2.record(z2.string(), z2.unknown()).optional().default({})
|
|
273
419
|
});
|
|
274
|
-
|
|
420
|
+
var MAX_SCHEMA_VERSION = 1;
|
|
421
|
+
var FILLO_SCHEMA_VERSION = 1;
|
|
422
|
+
var FILLO_SDK_VERSION = "0.5.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.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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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 =
|
|
917
|
+
const label = str2(spec.label, 500);
|
|
508
918
|
if (kind === "heading" || kind === "paragraph") {
|
|
509
|
-
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 =
|
|
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 =
|
|
530
|
-
if (spec.maxLabel) block.maxLabel =
|
|
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(
|
|
545
|
-
const description =
|
|
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:
|
|
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
|
|
677
|
-
|
|
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(
|
|
699
|
-
this.baseUrl =
|
|
700
|
-
this.key =
|
|
701
|
-
this.fetch =
|
|
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
|
-
|
|
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({
|
|
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,
|
|
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 (
|
|
808
|
-
session = await this.getUploadSession(
|
|
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:
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
852
|
-
const report = makeReporter(file.size,
|
|
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
|
-
|
|
864
|
-
() => this.fetch(transport.uploadUrl, { method: "POST", headers: auth, body: form, 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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 (
|
|
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,
|
|
1397
|
+
async driveUploadLoop(session, uploadUrl, file, options2) {
|
|
954
1398
|
let offset = session.uploadedBytes;
|
|
955
|
-
const report = makeReporter(file.size,
|
|
1399
|
+
const report = makeReporter(file.size, options2);
|
|
956
1400
|
report(offset);
|
|
957
1401
|
while (offset < file.size) {
|
|
958
|
-
|
|
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:
|
|
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 (
|
|
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,
|
|
1002
|
-
const report = makeReporter(file.size,
|
|
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:
|
|
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 (
|
|
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(
|
|
1026
|
-
return new FilloClient(
|
|
1469
|
+
function createClient(options2 = {}) {
|
|
1470
|
+
return new FilloClient(options2);
|
|
1027
1471
|
}
|
|
1028
|
-
async function provisionWorkspace(
|
|
1029
|
-
const base =
|
|
1030
|
-
const doFetch =
|
|
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:
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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 (!
|
|
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(
|
|
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:
|
|
1668
|
+
hp: options2.getHoneypot?.() ?? "",
|
|
1161
1669
|
elapsedMs: Date.now() - mountedAt,
|
|
1162
|
-
surface:
|
|
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 (
|
|
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
|
-
|
|
1209
|
-
|
|
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
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"zod": "^
|
|
32
|
+
"zod": "^4.4.3"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"tsup": "^8.4.0",
|