@usefillo/core 0.6.1 → 0.7.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 +1 -1
- package/dist/index.d.ts +270 -11
- package/dist/index.js +403 -29
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ const form = defineForm({
|
|
|
28
28
|
|
|
29
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
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 `
|
|
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 `responseLimit: { by: "browser", onRepeat: "keep" }` for browser-scoped duplicate prevention. Themes include `colorScheme: "light" | "dark" | "auto"` for renderer defaults.
|
|
32
32
|
|
|
33
33
|
Key exports: `createClient` / `FilloClient`, `defineForm`, `validateResponse`, `validateField`, `visibleBlocks`, and the schema types (`FormSchema`, `Field`, `FieldKind`, `FormTheme`, `ResponseData`, …).
|
|
34
34
|
|
package/dist/index.d.ts
CHANGED
|
@@ -148,6 +148,39 @@ interface FormPage {
|
|
|
148
148
|
title?: string;
|
|
149
149
|
blocks: Block[];
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Limit repeat responses. Absent = no limit (submit as often as you like).
|
|
153
|
+
*/
|
|
154
|
+
interface ResponseLimit {
|
|
155
|
+
/**
|
|
156
|
+
* Who counts as the same responder:
|
|
157
|
+
* - "browser": same device, anonymous — the SDK remembers this browser
|
|
158
|
+
* answered and sends a de-duplication key (ratings, polls, "was this
|
|
159
|
+
* helpful").
|
|
160
|
+
* - "field": the person self-identifies by answering `field` (an email or
|
|
161
|
+
* phone field). A self-claim — unverified, soft dedup — so the hosted link
|
|
162
|
+
* and anonymous embeds can still dedupe.
|
|
163
|
+
* - "identify": the identify() respondent (HMAC-verifiable). The strong
|
|
164
|
+
* path; only embedded surfaces that call identify() are recognized.
|
|
165
|
+
*/
|
|
166
|
+
by: "browser" | "field" | "identify";
|
|
167
|
+
/** The email/phone field whose answer identifies the person. `by: "field"` only. */
|
|
168
|
+
field?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Optionally sub-scope the limit by a field's answer (usually a hidden
|
|
171
|
+
* article/product/order id): "one response per responder PER that value" —
|
|
172
|
+
* e.g. one 👍 per visitor per article from a single shared form.
|
|
173
|
+
*/
|
|
174
|
+
scopeField?: string;
|
|
175
|
+
/**
|
|
176
|
+
* A repeat from the same responder: "keep" answers with the standing
|
|
177
|
+
* response (the first answer stands); "update" edits it in place (re-anchored
|
|
178
|
+
* to the current schema, `response.updated` webhook, previous answers
|
|
179
|
+
* prefilled for a verified respondent). "update" applies to `by: "identify"`
|
|
180
|
+
* only — a self-claim/browser must not overwrite another person's response.
|
|
181
|
+
*/
|
|
182
|
+
onRepeat: "keep" | "update";
|
|
183
|
+
}
|
|
151
184
|
interface FormSettings {
|
|
152
185
|
/**
|
|
153
186
|
* Default "button": respondents submit with the footer button. "auto" hides
|
|
@@ -160,17 +193,49 @@ interface FormSettings {
|
|
|
160
193
|
successMessage?: string;
|
|
161
194
|
redirectUrl?: string;
|
|
162
195
|
showProgress?: boolean;
|
|
163
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
* remember this browser has already answered and send a server-side
|
|
167
|
-
* de-duplication key with the response.
|
|
168
|
-
*/
|
|
169
|
-
submissionLimit?: "multiple" | "once_per_visitor";
|
|
196
|
+
/** Limit repeat responses (who counts as the same responder, and what a
|
|
197
|
+
* repeat does). Absent = no limit. See {@link ResponseLimit}. */
|
|
198
|
+
responseLimit?: ResponseLimit;
|
|
170
199
|
/** Notify this address on every submission. */
|
|
171
200
|
notifyEmail?: string;
|
|
172
201
|
/** Send respondents a receipt (to the first answered email field). */
|
|
173
202
|
sendReceipt?: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Save respondents' in-progress answers to Fillo (default off). The SDK
|
|
205
|
+
* autosaves as they type and restores on return, so long forms survive
|
|
206
|
+
* reloads and tab closes. Off by default because it stores answers before
|
|
207
|
+
* the respondent chooses to submit — the form owner opts in.
|
|
208
|
+
*/
|
|
209
|
+
saveProgress?: boolean;
|
|
210
|
+
/**
|
|
211
|
+
* Let workspace members read the answers a respondent has typed but not yet
|
|
212
|
+
* submitted (default off; requires saveProgress). This exposes pre-submission
|
|
213
|
+
* content to the owner's team, so it's a separate opt-in with its own consent
|
|
214
|
+
* framing — disclose it in your privacy policy. Respondents still see the
|
|
215
|
+
* "progress saved" notice. The content is visible only in the authenticated
|
|
216
|
+
* dashboard; the draft token stays the only public read capability.
|
|
217
|
+
*/
|
|
218
|
+
draftAnswersVisible?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* Email a respondent one "pick up where you left off" link when they leave a
|
|
221
|
+
* form idle (default off; requires saveProgress). Sent at most once per
|
|
222
|
+
* draft, to an address they entered or their verified account email — never
|
|
223
|
+
* with any answer content. Owner opt-in.
|
|
224
|
+
*/
|
|
225
|
+
resumeEmails?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Where a resume link should land for an embedded form (http(s) only). The
|
|
228
|
+
* draft reference travels in the URL fragment, which the SDK adopts on load;
|
|
229
|
+
* defaults to the hosted /f page when unset.
|
|
230
|
+
*/
|
|
231
|
+
resumeUrl?: string;
|
|
232
|
+
/**
|
|
233
|
+
* Email the form's notification address a daily digest of who dropped off —
|
|
234
|
+
* abandoned and open draft counts, where people stalled, and verified
|
|
235
|
+
* respondents by name (default off; requires notifyEmail). Never any answer
|
|
236
|
+
* content or resume links.
|
|
237
|
+
*/
|
|
238
|
+
draftDigest?: boolean;
|
|
174
239
|
}
|
|
175
240
|
interface FormSchema {
|
|
176
241
|
version: 1;
|
|
@@ -281,6 +346,15 @@ declare function isBlockVisible(block: Block, data: ResponseData): boolean;
|
|
|
281
346
|
declare function visibleBlocks(page: FormPage, data: ResponseData): Block[];
|
|
282
347
|
/** Every field in the form (across pages), in order. */
|
|
283
348
|
declare function allFields(form: FormSchema): Field[];
|
|
349
|
+
/**
|
|
350
|
+
* The scope key for a response limit: the string form of the answer to
|
|
351
|
+
* `settings.responseLimit.scopeField`, or null when there is no scope field or the
|
|
352
|
+
* answer is not a usable scalar. A non-scalar answer (checkbox boolean,
|
|
353
|
+
* multi_select/ranking array, matrix object) means "no scope" — the limit
|
|
354
|
+
* spans the whole form rather than silently mis-bucketing. Shared by the SDK's
|
|
355
|
+
* per-visitor key and the server's per-person dedup so both scope identically.
|
|
356
|
+
*/
|
|
357
|
+
declare function responseScopeValue(settings: FormSettings, data: ResponseData): string | null;
|
|
284
358
|
/** Fields currently visible given the response data — the set that gets validated. */
|
|
285
359
|
declare function visibleFields(form: FormSchema, data: ResponseData): Field[];
|
|
286
360
|
|
|
@@ -300,7 +374,9 @@ interface ValidationResult {
|
|
|
300
374
|
declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
|
|
301
375
|
|
|
302
376
|
declare const FILLO_SCHEMA_VERSION: 1;
|
|
303
|
-
|
|
377
|
+
/** Injected from package.json at build time (tsup define) — never hand-edited. */
|
|
378
|
+
declare const FILLO_SDK_VERSION: string;
|
|
379
|
+
declare function normalizeSettings(value: unknown): FormSettings;
|
|
304
380
|
interface SchemaValidationResult {
|
|
305
381
|
ok: boolean;
|
|
306
382
|
/** Present when ok — a normalized, structurally-valid schema. */
|
|
@@ -477,6 +553,8 @@ type SubmitResult = {
|
|
|
477
553
|
responseId: string;
|
|
478
554
|
/** True when the API accepted the request as an already-recorded visitor response. */
|
|
479
555
|
duplicate?: boolean;
|
|
556
|
+
/** True when an update-in-place limit (responseLimit onRepeat "update") updated the person's living response. */
|
|
557
|
+
updated?: boolean;
|
|
480
558
|
errors?: undefined;
|
|
481
559
|
} | {
|
|
482
560
|
ok: false;
|
|
@@ -495,9 +573,74 @@ interface SubmitMeta {
|
|
|
495
573
|
surface?: "default" | "headless";
|
|
496
574
|
/**
|
|
497
575
|
* Browser-scoped de-duplication key sent when a form opts into
|
|
498
|
-
* settings.
|
|
576
|
+
* settings.responseLimit.by = "browser".
|
|
499
577
|
*/
|
|
500
578
|
submissionKey?: string;
|
|
579
|
+
/**
|
|
580
|
+
* Saved-progress draft this submission completes (forms with
|
|
581
|
+
* settings.saveProgress). The server deletes the draft with the response
|
|
582
|
+
* commit so it can't be resumed after submitting.
|
|
583
|
+
*/
|
|
584
|
+
draft?: {
|
|
585
|
+
id: string;
|
|
586
|
+
token: string;
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* Host-app account context for this respondent (identify()). Recorded with
|
|
590
|
+
* the response and shown in the dashboard/webhooks as a CLAIM from the
|
|
591
|
+
* embedding page — it is metadata, not authentication. `id` is your own
|
|
592
|
+
* stable user/account id.
|
|
593
|
+
*/
|
|
594
|
+
respondent?: FilloRespondent;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The host app's account context for the person filling the form. Passed as
|
|
598
|
+
* the `respondent` option on FilloForm / FilloProvider / renderForm /
|
|
599
|
+
* createFormController; Fillo keys responses to it so the dashboard,
|
|
600
|
+
* webhooks, and integrations can say WHO answered.
|
|
601
|
+
*/
|
|
602
|
+
interface FilloRespondent {
|
|
603
|
+
/** Your stable user/account id — the identity key within your workspace. */
|
|
604
|
+
id: string;
|
|
605
|
+
email?: string;
|
|
606
|
+
name?: string;
|
|
607
|
+
/** Small primitive facts (plan, role, region…) — not a data warehouse. */
|
|
608
|
+
traits?: Record<string, string | number | boolean>;
|
|
609
|
+
/**
|
|
610
|
+
* Identity verification (optional): hex HMAC-SHA256 of `id`, computed on
|
|
611
|
+
* YOUR server with the workspace identity secret from Fillo settings. Once
|
|
612
|
+
* the workspace holds a secret, Fillo records identity only with a valid
|
|
613
|
+
* hash — never compute this in the browser or the secret leaks.
|
|
614
|
+
*/
|
|
615
|
+
hash?: string;
|
|
616
|
+
}
|
|
617
|
+
/** Wire shape of a saved-progress draft (settings.saveProgress forms). */
|
|
618
|
+
interface ResponseDraft {
|
|
619
|
+
id: string;
|
|
620
|
+
formId: string;
|
|
621
|
+
/** Partial answers exactly as last saved — validated only at submit. */
|
|
622
|
+
data: ResponseData;
|
|
623
|
+
/** 0-based page the respondent was on when the draft was last saved. */
|
|
624
|
+
page: number;
|
|
625
|
+
/** A freshly rotated bearer, returned only when adopting a resume link — the
|
|
626
|
+
* URL token is spent, so this is what future saves must use. */
|
|
627
|
+
token?: string;
|
|
628
|
+
}
|
|
629
|
+
interface CreatedDraft {
|
|
630
|
+
id: string;
|
|
631
|
+
/**
|
|
632
|
+
* Per-draft bearer, returned once at creation; sent back as
|
|
633
|
+
* X-Fillo-Draft-Token on every read/save/delete of this draft.
|
|
634
|
+
*/
|
|
635
|
+
token: string;
|
|
636
|
+
/** ISO timestamp; the server slides it forward on every save. */
|
|
637
|
+
expiresAt?: string;
|
|
638
|
+
/**
|
|
639
|
+
* True when a VERIFIED identity picked up its existing draft from another
|
|
640
|
+
* device — the token was rotated to this caller, and the draft's answers
|
|
641
|
+
* were left untouched (fetch them with getDraft to restore).
|
|
642
|
+
*/
|
|
643
|
+
existing?: boolean;
|
|
501
644
|
}
|
|
502
645
|
interface UploadProgress {
|
|
503
646
|
uploadedBytes: number;
|
|
@@ -552,6 +695,16 @@ declare class FilloClient {
|
|
|
552
695
|
syncForm(handle: string, schema: FormSchema, theme?: FormTheme | null): Promise<SyncFormResult>;
|
|
553
696
|
/** Submit a response. Returns per-field errors instead of throwing on validation failure. */
|
|
554
697
|
submit(formId: string, data: ResponseData, meta?: SubmitMeta): Promise<SubmitResult>;
|
|
698
|
+
/**
|
|
699
|
+
* The identified person's own living response on an upsert-mode form —
|
|
700
|
+
* used to prefill their answers for editing. Requires a VERIFIED identity
|
|
701
|
+
* ({id, hash}); anything else 404s (returned as null), because an
|
|
702
|
+
* unverified read would hand anyone's answers to any page script.
|
|
703
|
+
*/
|
|
704
|
+
fetchOwnResponse(formId: string, respondent: FilloRespondent): Promise<{
|
|
705
|
+
responseId: string;
|
|
706
|
+
data: ResponseData;
|
|
707
|
+
} | null>;
|
|
555
708
|
/**
|
|
556
709
|
* Open a respondent session for funnel analysis. Fire-and-forget: returns the
|
|
557
710
|
* session id, or null if tracking is unavailable (never blocks the form).
|
|
@@ -562,6 +715,33 @@ declare class FilloClient {
|
|
|
562
715
|
furthestPage?: number;
|
|
563
716
|
completed?: boolean;
|
|
564
717
|
}): void;
|
|
718
|
+
/**
|
|
719
|
+
* Create a saved-progress draft (forms with settings.saveProgress). Returns
|
|
720
|
+
* the draft id and its ownership token — the token is shown only once.
|
|
721
|
+
*/
|
|
722
|
+
createDraft(formId: string, body: {
|
|
723
|
+
data: ResponseData;
|
|
724
|
+
page?: number;
|
|
725
|
+
respondent?: FilloRespondent;
|
|
726
|
+
}): Promise<CreatedDraft>;
|
|
727
|
+
/** Fetch a draft to restore. 404s once expired, consumed, or deleted. When
|
|
728
|
+
* `adopt` is set (resume-link adoption), the server rotates the bearer and
|
|
729
|
+
* returns the new `token` — the URL token becomes single-use. */
|
|
730
|
+
getDraft(draftId: string, token: string, adopt?: boolean): Promise<ResponseDraft>;
|
|
731
|
+
/**
|
|
732
|
+
* Overwrite a draft's answers/page and slide its expiry. `keepalive` lets a
|
|
733
|
+
* tab-close flush outlive the page; browsers reject keepalive bodies over
|
|
734
|
+
* ~64KB, in which case this save is simply lost — the debounced autosaves
|
|
735
|
+
* every few keystrokes are the real persistence, the flush is a bonus.
|
|
736
|
+
*/
|
|
737
|
+
saveDraft(draftId: string, token: string, body: {
|
|
738
|
+
data: ResponseData;
|
|
739
|
+
page?: number;
|
|
740
|
+
}, opts?: {
|
|
741
|
+
keepalive?: boolean;
|
|
742
|
+
}): Promise<void>;
|
|
743
|
+
/** Discard a draft (the "Start over" path). */
|
|
744
|
+
deleteDraft(draftId: string, token: string): Promise<void>;
|
|
565
745
|
/** Current state of an upload session — used to resume after interruption. */
|
|
566
746
|
getUploadSession(sessionId: string, token?: string): Promise<UploadSession>;
|
|
567
747
|
/**
|
|
@@ -679,6 +859,13 @@ interface FormControllerOptions {
|
|
|
679
859
|
* respondent can retry.
|
|
680
860
|
*/
|
|
681
861
|
resolveFormId?: () => Promise<string>;
|
|
862
|
+
/**
|
|
863
|
+
* Host-app account context (identify()): who is filling this form, by your
|
|
864
|
+
* own user id. Sent with the submission and recorded as an unverified
|
|
865
|
+
* claim — it changes what the dashboard/webhooks can tell you, never what
|
|
866
|
+
* the respondent can do.
|
|
867
|
+
*/
|
|
868
|
+
respondent?: FilloRespondent;
|
|
682
869
|
}
|
|
683
870
|
interface FormControllerState {
|
|
684
871
|
data: ResponseData;
|
|
@@ -696,6 +883,27 @@ interface FormControllerState {
|
|
|
696
883
|
uploading: boolean;
|
|
697
884
|
/** Human-readable message for the last failed submit; cleared on edit/retry. */
|
|
698
885
|
submitError?: string;
|
|
886
|
+
/**
|
|
887
|
+
* True when `status` is "submitted" because the once-per-visitor gate
|
|
888
|
+
* restored a previous visit's response, not because a submit happened in
|
|
889
|
+
* this controller instance. Renderers use it to skip one-time "just
|
|
890
|
+
* submitted" reactions (moving focus to the success screen, redirecting) —
|
|
891
|
+
* otherwise every remount of an already-answered form replays them.
|
|
892
|
+
*/
|
|
893
|
+
restoredSubmission: boolean;
|
|
894
|
+
/**
|
|
895
|
+
* True when a saved-progress draft (settings.saveProgress) was restored
|
|
896
|
+
* into this fill — answers and/or page position came from a previous
|
|
897
|
+
* visit. Renderers use it to show a "continuing where you left off"
|
|
898
|
+
* notice with a Start over action (resetDraft).
|
|
899
|
+
*/
|
|
900
|
+
resumedDraft: boolean;
|
|
901
|
+
/**
|
|
902
|
+
* True when an update-in-place limit prefilled the VERIFIED respondent's own
|
|
903
|
+
* previous answers — submitting updates that response in place. Renderers
|
|
904
|
+
* show an "updating your earlier response" notice.
|
|
905
|
+
*/
|
|
906
|
+
editingPrevious: boolean;
|
|
699
907
|
}
|
|
700
908
|
interface FormController {
|
|
701
909
|
/** Stable snapshot — same reference until something changes (safe for useSyncExternalStore). */
|
|
@@ -720,7 +928,21 @@ interface FormController {
|
|
|
720
928
|
form?: FormSchema;
|
|
721
929
|
formId?: string;
|
|
722
930
|
client?: FilloClient;
|
|
931
|
+
/** Late-bind identify() context — host sessions often resolve after mount. */
|
|
932
|
+
respondent?: FilloRespondent;
|
|
723
933
|
}): void;
|
|
934
|
+
/**
|
|
935
|
+
* Persist any unsaved draft progress right now (settings.saveProgress
|
|
936
|
+
* forms). Renderers call this on pagehide/visibility-hidden so the last
|
|
937
|
+
* keystrokes survive a tab close; a no-op when there's nothing to save.
|
|
938
|
+
*/
|
|
939
|
+
flushDraft(): void;
|
|
940
|
+
/**
|
|
941
|
+
* Discard the saved draft and reset to a fresh fill: answers back to
|
|
942
|
+
* initialData + URL prefill, first page, errors cleared. The "Start over"
|
|
943
|
+
* action next to the resume notice.
|
|
944
|
+
*/
|
|
945
|
+
resetDraft(): void;
|
|
724
946
|
/** Drop all listeners. */
|
|
725
947
|
destroy(): void;
|
|
726
948
|
}
|
|
@@ -752,7 +974,7 @@ declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubm
|
|
|
752
974
|
* (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
|
|
753
975
|
* names are public API — renames are breaking; additions are minors.
|
|
754
976
|
*/
|
|
755
|
-
declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success"];
|
|
977
|
+
declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success", "resume"];
|
|
756
978
|
type FilloSlot = (typeof FILLO_SLOTS)[number];
|
|
757
979
|
/** data-* names emitted alongside the stable fillo-* classes. */
|
|
758
980
|
declare const FILLO_DATA_ATTRS: {
|
|
@@ -764,6 +986,8 @@ declare const FILLO_DATA_ATTRS: {
|
|
|
764
986
|
readonly field: "data-field";
|
|
765
987
|
readonly invalid: "data-invalid";
|
|
766
988
|
readonly required: "data-required";
|
|
989
|
+
/** Option id, on each choice row (pre-existing selector hook). */
|
|
990
|
+
readonly option: "data-option";
|
|
767
991
|
/** Selected option row / active scale step / active star. */
|
|
768
992
|
readonly selected: "data-selected";
|
|
769
993
|
/** Checkbox / toggle checked state. */
|
|
@@ -806,6 +1030,35 @@ interface FilloAppearance {
|
|
|
806
1030
|
declare function resolveSlotClass(appearance: FilloAppearance | undefined, state: SlotState): string;
|
|
807
1031
|
/** Append resolved consumer classes to a base fillo-* class string. */
|
|
808
1032
|
declare function slotClass(base: string, appearance: FilloAppearance | undefined, state: SlotState): string;
|
|
1033
|
+
/** The CSS custom properties the default theme runs on. `themeProp` names the
|
|
1034
|
+
* FormTheme key that sets a token (inline, synced to the hosted page); tokens
|
|
1035
|
+
* without one are stylesheet-only and overridable from consumer CSS. */
|
|
1036
|
+
declare const FILLO_THEME_VARS: readonly [{
|
|
1037
|
+
readonly var: "--fillo-primary";
|
|
1038
|
+
readonly themeProp: "primary";
|
|
1039
|
+
}, {
|
|
1040
|
+
readonly var: "--fillo-bg";
|
|
1041
|
+
readonly themeProp: "background";
|
|
1042
|
+
}, {
|
|
1043
|
+
readonly var: "--fillo-text";
|
|
1044
|
+
readonly themeProp: "text";
|
|
1045
|
+
}, {
|
|
1046
|
+
readonly var: "--fillo-radius";
|
|
1047
|
+
readonly themeProp: "radius";
|
|
1048
|
+
}, {
|
|
1049
|
+
readonly var: "--fillo-font";
|
|
1050
|
+
readonly themeProp: "fontFamily";
|
|
1051
|
+
}, {
|
|
1052
|
+
readonly var: "--fillo-muted";
|
|
1053
|
+
}, {
|
|
1054
|
+
readonly var: "--fillo-border";
|
|
1055
|
+
}, {
|
|
1056
|
+
readonly var: "--fillo-control-bg";
|
|
1057
|
+
}, {
|
|
1058
|
+
readonly var: "--fillo-error";
|
|
1059
|
+
}, {
|
|
1060
|
+
readonly var: "--fillo-primary-contrast";
|
|
1061
|
+
}];
|
|
809
1062
|
|
|
810
1063
|
/**
|
|
811
1064
|
* Every visitor-facing string the default renderers emit, overridable as a
|
|
@@ -838,6 +1091,12 @@ interface FilloStrings {
|
|
|
838
1091
|
loadFailedNetwork: string;
|
|
839
1092
|
loadFailed: string;
|
|
840
1093
|
renderFailed: string;
|
|
1094
|
+
/** Saved-progress notice when a draft restored earlier answers. */
|
|
1095
|
+
resumeNotice: string;
|
|
1096
|
+
/** Upsert-mode notice when the person's previous response was prefilled. */
|
|
1097
|
+
editNotice: string;
|
|
1098
|
+
/** The discard action next to the resume notice. */
|
|
1099
|
+
resumeStartOver: string;
|
|
841
1100
|
}
|
|
842
1101
|
declare const DEFAULT_STRINGS: FilloStrings;
|
|
843
1102
|
declare function resolveStrings(overrides?: Partial<FilloStrings>): FilloStrings;
|
|
@@ -1004,4 +1263,4 @@ declare class Sha1 {
|
|
|
1004
1263
|
}
|
|
1005
1264
|
declare const sha1Base64: (bytes: Uint8Array) => string;
|
|
1006
1265
|
|
|
1007
|
-
export { type AutoSubmitContext, 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, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_DATA_ATTRS, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, FilloJsxError, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, 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 SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, parsePhone, pipeBlock, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, when };
|
|
1266
|
+
export { type AutoSubmitContext, 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 CreatedDraft, type CustomField, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_DATA_ATTRS, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, FilloJsxError, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, 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 ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, when };
|
package/dist/index.js
CHANGED
|
@@ -77,6 +77,12 @@ function visibleBlocks(page, data) {
|
|
|
77
77
|
function allFields(form) {
|
|
78
78
|
return form.pages.flatMap((p) => p.blocks.filter(isField));
|
|
79
79
|
}
|
|
80
|
+
function responseScopeValue(settings, data) {
|
|
81
|
+
const field2 = settings.responseLimit?.scopeField;
|
|
82
|
+
if (!field2) return null;
|
|
83
|
+
const value = data[field2];
|
|
84
|
+
return typeof value === "string" || typeof value === "number" ? String(value) : null;
|
|
85
|
+
}
|
|
80
86
|
function visibleFields(form, data) {
|
|
81
87
|
const fields = allFields(form);
|
|
82
88
|
const visible = visibleFieldIds(fields, data);
|
|
@@ -594,7 +600,7 @@ var schemaShape = z2.object({
|
|
|
594
600
|
});
|
|
595
601
|
var MAX_SCHEMA_VERSION = 1;
|
|
596
602
|
var FILLO_SCHEMA_VERSION = 1;
|
|
597
|
-
var FILLO_SDK_VERSION = "0.
|
|
603
|
+
var FILLO_SDK_VERSION = true ? "0.7.0" : "0.0.0-dev";
|
|
598
604
|
function str(value, max, fallback = "") {
|
|
599
605
|
return typeof value === "string" ? value.trim().slice(0, max) : fallback;
|
|
600
606
|
}
|
|
@@ -668,10 +674,25 @@ function normalizeUrl(value) {
|
|
|
668
674
|
return void 0;
|
|
669
675
|
}
|
|
670
676
|
}
|
|
677
|
+
function normalizeResponseLimit(value) {
|
|
678
|
+
if (!value || typeof value !== "object") return void 0;
|
|
679
|
+
const rec = value;
|
|
680
|
+
const by = rec.by === "browser" || rec.by === "field" || rec.by === "identify" ? rec.by : void 0;
|
|
681
|
+
if (!by) return void 0;
|
|
682
|
+
const field2 = by === "field" ? optionalStr(rec.field, 128) : void 0;
|
|
683
|
+
if (by === "field" && !field2) return void 0;
|
|
684
|
+
const scopeField = optionalStr(rec.scopeField, 128);
|
|
685
|
+
const onRepeat = rec.onRepeat === "update" && by === "identify" ? "update" : "keep";
|
|
686
|
+
return {
|
|
687
|
+
by,
|
|
688
|
+
...field2 ? { field: field2 } : {},
|
|
689
|
+
...scopeField ? { scopeField } : {},
|
|
690
|
+
onRepeat
|
|
691
|
+
};
|
|
692
|
+
}
|
|
671
693
|
function normalizeSettings(value) {
|
|
672
694
|
const rec = value && typeof value === "object" ? value : {};
|
|
673
695
|
const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
|
|
674
|
-
const submissionLimit = rec.submissionLimit === "once_per_visitor" || rec.submissionLimit === "multiple" ? rec.submissionLimit : void 0;
|
|
675
696
|
return {
|
|
676
697
|
submitMode,
|
|
677
698
|
submitLabel: optionalStr(rec.submitLabel, 100),
|
|
@@ -679,9 +700,14 @@ function normalizeSettings(value) {
|
|
|
679
700
|
successMessage: optionalStr(rec.successMessage, 5e3),
|
|
680
701
|
redirectUrl: normalizeUrl(rec.redirectUrl),
|
|
681
702
|
showProgress: bool(rec.showProgress),
|
|
682
|
-
|
|
703
|
+
responseLimit: normalizeResponseLimit(rec.responseLimit),
|
|
683
704
|
notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
|
|
684
|
-
sendReceipt: bool(rec.sendReceipt)
|
|
705
|
+
sendReceipt: bool(rec.sendReceipt),
|
|
706
|
+
saveProgress: bool(rec.saveProgress),
|
|
707
|
+
draftAnswersVisible: bool(rec.draftAnswersVisible),
|
|
708
|
+
resumeEmails: bool(rec.resumeEmails),
|
|
709
|
+
resumeUrl: normalizeUrl(rec.resumeUrl),
|
|
710
|
+
draftDigest: bool(rec.draftDigest)
|
|
685
711
|
};
|
|
686
712
|
}
|
|
687
713
|
function cleanObject(obj) {
|
|
@@ -1049,8 +1075,7 @@ function createEmptyForm(title = "Untitled form") {
|
|
|
1049
1075
|
submitLabel: "Submit",
|
|
1050
1076
|
successTitle: "Thanks!",
|
|
1051
1077
|
successMessage: "Your response has been recorded.",
|
|
1052
|
-
showProgress: true
|
|
1053
|
-
submissionLimit: "multiple"
|
|
1078
|
+
showProgress: true
|
|
1054
1079
|
}
|
|
1055
1080
|
};
|
|
1056
1081
|
}
|
|
@@ -1413,7 +1438,24 @@ var FilloClient = class {
|
|
|
1413
1438
|
throw new FilloError(message, res.status, retryAfterSec(res));
|
|
1414
1439
|
}
|
|
1415
1440
|
const body = await res.json();
|
|
1416
|
-
return { ok: true, responseId: body.id, duplicate: body.duplicate };
|
|
1441
|
+
return { ok: true, responseId: body.id, duplicate: body.duplicate, updated: body.updated };
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* The identified person's own living response on an upsert-mode form —
|
|
1445
|
+
* used to prefill their answers for editing. Requires a VERIFIED identity
|
|
1446
|
+
* ({id, hash}); anything else 404s (returned as null), because an
|
|
1447
|
+
* unverified read would hand anyone's answers to any page script.
|
|
1448
|
+
*/
|
|
1449
|
+
async fetchOwnResponse(formId, respondent) {
|
|
1450
|
+
try {
|
|
1451
|
+
return await this.json(
|
|
1452
|
+
`/forms/${encodeURIComponent(formId)}/respondent-response`,
|
|
1453
|
+
{ method: "POST", body: JSON.stringify({ respondent }) }
|
|
1454
|
+
);
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
if (isFilloError(err) && (err.status === 404 || err.status === 403)) return null;
|
|
1457
|
+
throw err;
|
|
1458
|
+
}
|
|
1417
1459
|
}
|
|
1418
1460
|
/**
|
|
1419
1461
|
* Open a respondent session for funnel analysis. Fire-and-forget: returns the
|
|
@@ -1448,6 +1490,45 @@ var FilloClient = class {
|
|
|
1448
1490
|
} catch {
|
|
1449
1491
|
}
|
|
1450
1492
|
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Create a saved-progress draft (forms with settings.saveProgress). Returns
|
|
1495
|
+
* the draft id and its ownership token — the token is shown only once.
|
|
1496
|
+
*/
|
|
1497
|
+
createDraft(formId, body) {
|
|
1498
|
+
return this.json(`/forms/${encodeURIComponent(formId)}/drafts`, {
|
|
1499
|
+
method: "POST",
|
|
1500
|
+
body: JSON.stringify(body)
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
/** Fetch a draft to restore. 404s once expired, consumed, or deleted. When
|
|
1504
|
+
* `adopt` is set (resume-link adoption), the server rotates the bearer and
|
|
1505
|
+
* returns the new `token` — the URL token becomes single-use. */
|
|
1506
|
+
getDraft(draftId, token, adopt) {
|
|
1507
|
+
return this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1508
|
+
headers: adopt ? { "X-Fillo-Draft-Token": token, "X-Fillo-Draft-Adopt": "1" } : { "X-Fillo-Draft-Token": token }
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Overwrite a draft's answers/page and slide its expiry. `keepalive` lets a
|
|
1513
|
+
* tab-close flush outlive the page; browsers reject keepalive bodies over
|
|
1514
|
+
* ~64KB, in which case this save is simply lost — the debounced autosaves
|
|
1515
|
+
* every few keystrokes are the real persistence, the flush is a bonus.
|
|
1516
|
+
*/
|
|
1517
|
+
async saveDraft(draftId, token, body, opts) {
|
|
1518
|
+
await this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1519
|
+
method: "PATCH",
|
|
1520
|
+
headers: { "X-Fillo-Draft-Token": token },
|
|
1521
|
+
body: JSON.stringify(body),
|
|
1522
|
+
...opts?.keepalive ? { keepalive: true } : {}
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
/** Discard a draft (the "Start over" path). */
|
|
1526
|
+
async deleteDraft(draftId, token) {
|
|
1527
|
+
await this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1528
|
+
method: "DELETE",
|
|
1529
|
+
headers: { "X-Fillo-Draft-Token": token }
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1451
1532
|
/** Current state of an upload session — used to resume after interruption. */
|
|
1452
1533
|
getUploadSession(sessionId, token) {
|
|
1453
1534
|
return this.json(
|
|
@@ -1806,6 +1887,14 @@ function prefillFromParams(form, params) {
|
|
|
1806
1887
|
// src/controller.ts
|
|
1807
1888
|
var EMPTY_PAGE = { id: "empty", blocks: [] };
|
|
1808
1889
|
var warnedNoClient = false;
|
|
1890
|
+
var warnedNoFormId = false;
|
|
1891
|
+
function warnNoFormId() {
|
|
1892
|
+
if (warnedNoFormId) return;
|
|
1893
|
+
warnedNoFormId = true;
|
|
1894
|
+
console.warn(
|
|
1895
|
+
"[fillo] This form has a client but no submission target \u2014 a bare schema can't sync. Wrap it in defineForm({ id, pages }) (or author it with <Fillo.Form id=\u2026>) so it gets a workspace handle, or pass formId for a dashboard-built form: https://fillo.so/docs"
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1809
1898
|
function warnNoClient() {
|
|
1810
1899
|
if (warnedNoClient) return;
|
|
1811
1900
|
warnedNoClient = true;
|
|
@@ -1834,25 +1923,36 @@ function randomSubmissionKey() {
|
|
|
1834
1923
|
}
|
|
1835
1924
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1836
1925
|
}
|
|
1837
|
-
function
|
|
1926
|
+
function readStoredJson(memory, storageKey2, memoryKey, isValid) {
|
|
1838
1927
|
const store = browserStorage();
|
|
1839
|
-
if (!store) return
|
|
1928
|
+
if (!store) return memory.get(memoryKey) ?? null;
|
|
1840
1929
|
try {
|
|
1841
|
-
const parsed = JSON.parse(store.getItem(
|
|
1842
|
-
if (parsed &&
|
|
1930
|
+
const parsed = JSON.parse(store.getItem(storageKey2) ?? "null");
|
|
1931
|
+
if (parsed && isValid(parsed)) return parsed;
|
|
1843
1932
|
} catch {
|
|
1844
1933
|
}
|
|
1845
1934
|
return null;
|
|
1846
1935
|
}
|
|
1847
|
-
function
|
|
1848
|
-
|
|
1936
|
+
function writeStoredJson(memory, storageKey2, memoryKey, value) {
|
|
1937
|
+
memory.set(memoryKey, value);
|
|
1849
1938
|
const store = browserStorage();
|
|
1850
1939
|
if (!store) return;
|
|
1851
1940
|
try {
|
|
1852
|
-
store.setItem(
|
|
1941
|
+
store.setItem(storageKey2, JSON.stringify(value));
|
|
1853
1942
|
} catch {
|
|
1854
1943
|
}
|
|
1855
1944
|
}
|
|
1945
|
+
function readStoredSubmission(formId) {
|
|
1946
|
+
return readStoredJson(
|
|
1947
|
+
memorySubmissionKeys,
|
|
1948
|
+
submissionStorageKey(formId),
|
|
1949
|
+
formId,
|
|
1950
|
+
(p) => typeof p.key === "string"
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
function writeStoredSubmission(formId, value) {
|
|
1954
|
+
writeStoredJson(memorySubmissionKeys, submissionStorageKey(formId), formId, value);
|
|
1955
|
+
}
|
|
1856
1956
|
function ensureSubmissionKey(formId) {
|
|
1857
1957
|
const existing = readStoredSubmission(formId);
|
|
1858
1958
|
if (existing?.key) return existing.key;
|
|
@@ -1868,10 +1968,40 @@ function markSubmitted(formId, responseId) {
|
|
|
1868
1968
|
responseId
|
|
1869
1969
|
});
|
|
1870
1970
|
}
|
|
1871
|
-
function
|
|
1872
|
-
|
|
1873
|
-
return
|
|
1971
|
+
function visitorSubmissionKeyId(form, formId, data) {
|
|
1972
|
+
const scope = responseScopeValue(form.settings, data);
|
|
1973
|
+
return scope === null ? formId : `${formId}::${scope}`;
|
|
1974
|
+
}
|
|
1975
|
+
function hasSubmittedOnce(form, formId, data) {
|
|
1976
|
+
if (form.settings.responseLimit?.by !== "browser" || !formId) return false;
|
|
1977
|
+
return Boolean(readStoredSubmission(visitorSubmissionKeyId(form, formId, data))?.submittedAt);
|
|
1978
|
+
}
|
|
1979
|
+
var memoryDraftRefs = /* @__PURE__ */ new Map();
|
|
1980
|
+
function draftStorageKey(formId) {
|
|
1981
|
+
return `fillo:draft:${formId}`;
|
|
1982
|
+
}
|
|
1983
|
+
function readDraftRef(formId) {
|
|
1984
|
+
return readStoredJson(memoryDraftRefs, draftStorageKey(formId), formId, (p) => {
|
|
1985
|
+
const ref = p;
|
|
1986
|
+
return typeof ref.id === "string" && typeof ref.token === "string";
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
function writeDraftRef(formId, ref) {
|
|
1990
|
+
writeStoredJson(memoryDraftRefs, draftStorageKey(formId), formId, ref);
|
|
1991
|
+
}
|
|
1992
|
+
function clearDraftRef(formId) {
|
|
1993
|
+
memoryDraftRefs.delete(formId);
|
|
1994
|
+
const store = browserStorage();
|
|
1995
|
+
if (!store) return;
|
|
1996
|
+
try {
|
|
1997
|
+
store.removeItem(draftStorageKey(formId));
|
|
1998
|
+
} catch {
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
function isDraftGone(err) {
|
|
2002
|
+
return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
|
|
1874
2003
|
}
|
|
2004
|
+
var DRAFT_DEBOUNCE_MS = 1500;
|
|
1875
2005
|
function submitFailureMessage(err) {
|
|
1876
2006
|
if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
|
|
1877
2007
|
return "Couldn't reach the server \u2014 check your connection and try again. If this keeps happening, a browser extension, firewall, or the site's Content-Security-Policy may be blocking the request.";
|
|
@@ -1883,15 +2013,13 @@ function createFormController(options2) {
|
|
|
1883
2013
|
let form = initial.schema;
|
|
1884
2014
|
let formId = options2.formId;
|
|
1885
2015
|
let client = options2.client;
|
|
2016
|
+
let respondent = options2.respondent;
|
|
1886
2017
|
let data = { ...options2.initialData ?? {} };
|
|
1887
2018
|
let errors = {};
|
|
1888
2019
|
let pageIndex = 0;
|
|
1889
2020
|
let status = "idle";
|
|
2021
|
+
let restoredSubmission = false;
|
|
1890
2022
|
queueMicrotask(() => {
|
|
1891
|
-
if (status === "idle" && hasSubmittedOnce(form, formId)) {
|
|
1892
|
-
status = "submitted";
|
|
1893
|
-
notify();
|
|
1894
|
-
}
|
|
1895
2023
|
if (typeof location !== "undefined" && location.search) {
|
|
1896
2024
|
const params = Object.fromEntries(new URLSearchParams(location.search));
|
|
1897
2025
|
let merged = null;
|
|
@@ -1906,6 +2034,31 @@ function createFormController(options2) {
|
|
|
1906
2034
|
notify();
|
|
1907
2035
|
}
|
|
1908
2036
|
}
|
|
2037
|
+
if (status === "idle" && hasSubmittedOnce(form, formId, data)) {
|
|
2038
|
+
status = "submitted";
|
|
2039
|
+
restoredSubmission = true;
|
|
2040
|
+
notify();
|
|
2041
|
+
}
|
|
2042
|
+
let adopting = false;
|
|
2043
|
+
if (form.settings.saveProgress && formId && client && typeof location !== "undefined" && location.hash.includes("fillo-draft=")) {
|
|
2044
|
+
const match = /fillo-draft=([^&]+)/.exec(location.hash);
|
|
2045
|
+
const raw = match?.[1] ? decodeURIComponent(match[1]) : "";
|
|
2046
|
+
const dot = raw.indexOf(".");
|
|
2047
|
+
if (dot > 0) {
|
|
2048
|
+
adopting = true;
|
|
2049
|
+
const did = raw.slice(0, dot);
|
|
2050
|
+
const dtok = raw.slice(dot + 1);
|
|
2051
|
+
client.getDraft(did, dtok, true).then((d) => {
|
|
2052
|
+
writeDraftRef(formId, { id: did, token: d.token ?? dtok });
|
|
2053
|
+
try {
|
|
2054
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
2055
|
+
} catch {
|
|
2056
|
+
}
|
|
2057
|
+
}).catch(() => {
|
|
2058
|
+
}).finally(() => maybeRestoreDraft());
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if (!adopting) maybeRestoreDraft();
|
|
1909
2062
|
});
|
|
1910
2063
|
let submitError;
|
|
1911
2064
|
const uploadingFields = /* @__PURE__ */ new Set();
|
|
@@ -1914,6 +2067,16 @@ function createFormController(options2) {
|
|
|
1914
2067
|
const idempotencyKey = randomSubmissionKey();
|
|
1915
2068
|
let sessionId = null;
|
|
1916
2069
|
let sessionStarted = false;
|
|
2070
|
+
let resumedDraft = false;
|
|
2071
|
+
let editingPrevious = false;
|
|
2072
|
+
let editAttempted = false;
|
|
2073
|
+
let draftRef = null;
|
|
2074
|
+
let draftTimer = null;
|
|
2075
|
+
let draftChain = Promise.resolve();
|
|
2076
|
+
let draftDirty = false;
|
|
2077
|
+
let draftDisabled = false;
|
|
2078
|
+
let draftRestoreAttempted = false;
|
|
2079
|
+
let draftAffinityAttempted = false;
|
|
1917
2080
|
function computeSnapshot() {
|
|
1918
2081
|
const pageCount = form.pages.length;
|
|
1919
2082
|
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
@@ -1929,7 +2092,10 @@ function createFormController(options2) {
|
|
|
1929
2092
|
isFirstPage: clamped === 0,
|
|
1930
2093
|
isLastPage: clamped === pageCount - 1,
|
|
1931
2094
|
uploading: uploadingFields.size > 0,
|
|
1932
|
-
submitError
|
|
2095
|
+
submitError,
|
|
2096
|
+
restoredSubmission,
|
|
2097
|
+
resumedDraft,
|
|
2098
|
+
editingPrevious
|
|
1933
2099
|
};
|
|
1934
2100
|
}
|
|
1935
2101
|
let snapshot = computeSnapshot();
|
|
@@ -1955,6 +2121,153 @@ function createFormController(options2) {
|
|
|
1955
2121
|
}
|
|
1956
2122
|
return pageErrors;
|
|
1957
2123
|
}
|
|
2124
|
+
function draftsEnabled() {
|
|
2125
|
+
return Boolean(form.settings.saveProgress && client && formId && !draftDisabled);
|
|
2126
|
+
}
|
|
2127
|
+
function knownFieldIds() {
|
|
2128
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2129
|
+
for (const page of form.pages) {
|
|
2130
|
+
for (const block of page.blocks) if (isField(block)) ids.add(block.id);
|
|
2131
|
+
}
|
|
2132
|
+
return ids;
|
|
2133
|
+
}
|
|
2134
|
+
function applyDraftSnapshot(draft) {
|
|
2135
|
+
if (status !== "idle" || sessionStarted) return;
|
|
2136
|
+
const ids = knownFieldIds();
|
|
2137
|
+
let merged = null;
|
|
2138
|
+
for (const [key, value] of Object.entries(draft.data ?? {})) {
|
|
2139
|
+
if (data[key] === void 0 && ids.has(key)) {
|
|
2140
|
+
merged = merged ?? { ...data };
|
|
2141
|
+
merged[key] = value;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
const target = typeof draft.page === "number" && Number.isFinite(draft.page) ? Math.max(0, Math.min(Math.floor(draft.page), form.pages.length - 1)) : 0;
|
|
2145
|
+
const movePage = target > 0 && pageIndex === 0;
|
|
2146
|
+
if (!merged && !movePage) return;
|
|
2147
|
+
if (merged) data = merged;
|
|
2148
|
+
if (movePage) pageIndex = target;
|
|
2149
|
+
resumedDraft = true;
|
|
2150
|
+
notify();
|
|
2151
|
+
}
|
|
2152
|
+
function maybeRestoreDraft() {
|
|
2153
|
+
if (status !== "idle") return;
|
|
2154
|
+
if (!draftsEnabled()) {
|
|
2155
|
+
maybePrefillOwnResponse();
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
const fid = formId;
|
|
2159
|
+
const c = client;
|
|
2160
|
+
const ref = draftRef ?? readDraftRef(fid);
|
|
2161
|
+
if (ref && !draftRestoreAttempted) {
|
|
2162
|
+
draftRestoreAttempted = true;
|
|
2163
|
+
draftRef = ref;
|
|
2164
|
+
void c.getDraft(ref.id, ref.token).then(applyDraftSnapshot).catch((err) => {
|
|
2165
|
+
if (isDraftGone(err)) {
|
|
2166
|
+
clearDraftRef(fid);
|
|
2167
|
+
draftRef = null;
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
if (!ref && !draftAffinityAttempted && respondent?.hash) {
|
|
2173
|
+
draftAffinityAttempted = true;
|
|
2174
|
+
void c.createDraft(fid, { data: {}, page: 0, respondent }).then((created) => {
|
|
2175
|
+
if (draftRef) return;
|
|
2176
|
+
draftRef = { id: created.id, token: created.token };
|
|
2177
|
+
writeDraftRef(fid, draftRef);
|
|
2178
|
+
if (created.existing) {
|
|
2179
|
+
return c.getDraft(created.id, created.token).then(applyDraftSnapshot);
|
|
2180
|
+
}
|
|
2181
|
+
maybePrefillOwnResponse();
|
|
2182
|
+
}).catch((err) => {
|
|
2183
|
+
if (!(isFilloError(err) && err.status !== void 0 && err.status >= 400 && err.status < 500)) {
|
|
2184
|
+
draftAffinityAttempted = false;
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
function maybePrefillOwnResponse() {
|
|
2190
|
+
if (editAttempted || form.settings.responseLimit?.onRepeat !== "update") return;
|
|
2191
|
+
if (!client || !formId || !respondent?.hash || status !== "idle") return;
|
|
2192
|
+
editAttempted = true;
|
|
2193
|
+
const c = client;
|
|
2194
|
+
void c.fetchOwnResponse(formId, respondent).then((own) => {
|
|
2195
|
+
if (!own || status !== "idle" || sessionStarted || resumedDraft) return;
|
|
2196
|
+
applyDraftSnapshot({ data: own.data, page: 0 });
|
|
2197
|
+
if (resumedDraft) {
|
|
2198
|
+
resumedDraft = false;
|
|
2199
|
+
editingPrevious = true;
|
|
2200
|
+
notify();
|
|
2201
|
+
}
|
|
2202
|
+
}).catch(() => {
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
function persistDraft(keepalive = false) {
|
|
2206
|
+
if (draftTimer) {
|
|
2207
|
+
clearTimeout(draftTimer);
|
|
2208
|
+
draftTimer = null;
|
|
2209
|
+
}
|
|
2210
|
+
if (!draftsEnabled() || status !== "idle" || !draftDirty) return;
|
|
2211
|
+
const fid = formId;
|
|
2212
|
+
const c = client;
|
|
2213
|
+
draftDirty = false;
|
|
2214
|
+
draftChain = draftChain.then(async () => {
|
|
2215
|
+
const payload = {
|
|
2216
|
+
data,
|
|
2217
|
+
page: Math.max(0, Math.min(pageIndex, form.pages.length - 1))
|
|
2218
|
+
};
|
|
2219
|
+
const ref = draftRef ?? readDraftRef(fid);
|
|
2220
|
+
try {
|
|
2221
|
+
if (ref) {
|
|
2222
|
+
try {
|
|
2223
|
+
await c.saveDraft(ref.id, ref.token, payload, keepalive ? { keepalive: true } : void 0);
|
|
2224
|
+
draftRef = ref;
|
|
2225
|
+
return;
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
if (!isDraftGone(err)) throw err;
|
|
2228
|
+
clearDraftRef(fid);
|
|
2229
|
+
draftRef = null;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
const created = await c.createDraft(
|
|
2233
|
+
fid,
|
|
2234
|
+
respondent?.hash ? { ...payload, respondent } : payload
|
|
2235
|
+
);
|
|
2236
|
+
draftRef = { id: created.id, token: created.token };
|
|
2237
|
+
writeDraftRef(fid, draftRef);
|
|
2238
|
+
if (created.existing) draftDirty = true;
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
if (isFilloError(err) && err.status !== void 0 && err.status >= 400 && err.status < 500 && err.status !== 429) {
|
|
2241
|
+
draftDisabled = true;
|
|
2242
|
+
} else {
|
|
2243
|
+
draftDirty = true;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
function scheduleDraftSave() {
|
|
2249
|
+
if (!draftsEnabled() || status !== "idle") return;
|
|
2250
|
+
draftDirty = true;
|
|
2251
|
+
if (draftTimer) clearTimeout(draftTimer);
|
|
2252
|
+
draftTimer = setTimeout(() => {
|
|
2253
|
+
draftTimer = null;
|
|
2254
|
+
persistDraft();
|
|
2255
|
+
}, DRAFT_DEBOUNCE_MS);
|
|
2256
|
+
}
|
|
2257
|
+
function checkpointDraft() {
|
|
2258
|
+
if (!draftsEnabled() || !sessionStarted && !draftRef) return;
|
|
2259
|
+
draftDirty = true;
|
|
2260
|
+
persistDraft();
|
|
2261
|
+
}
|
|
2262
|
+
function discardDraftState() {
|
|
2263
|
+
if (draftTimer) {
|
|
2264
|
+
clearTimeout(draftTimer);
|
|
2265
|
+
draftTimer = null;
|
|
2266
|
+
}
|
|
2267
|
+
draftDirty = false;
|
|
2268
|
+
if (formId) clearDraftRef(formId);
|
|
2269
|
+
draftRef = null;
|
|
2270
|
+
}
|
|
1958
2271
|
function setValue(fieldId, value) {
|
|
1959
2272
|
if (!sessionStarted && client && formId) {
|
|
1960
2273
|
sessionStarted = true;
|
|
@@ -1967,6 +2280,7 @@ function createFormController(options2) {
|
|
|
1967
2280
|
errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
|
|
1968
2281
|
}
|
|
1969
2282
|
notify();
|
|
2283
|
+
scheduleDraftSave();
|
|
1970
2284
|
}
|
|
1971
2285
|
function setUploading(fieldId, busy) {
|
|
1972
2286
|
if (busy) uploadingFields.add(fieldId);
|
|
@@ -1987,11 +2301,13 @@ function createFormController(options2) {
|
|
|
1987
2301
|
pageIndex = Math.min(clamped + 1, pageCount - 1);
|
|
1988
2302
|
if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
|
|
1989
2303
|
notify();
|
|
2304
|
+
checkpointDraft();
|
|
1990
2305
|
}
|
|
1991
2306
|
function back() {
|
|
1992
2307
|
errors = {};
|
|
1993
2308
|
pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
|
|
1994
2309
|
notify();
|
|
2310
|
+
checkpointDraft();
|
|
1995
2311
|
}
|
|
1996
2312
|
async function submit() {
|
|
1997
2313
|
if (status === "submitting" || status === "submitted") return;
|
|
@@ -2019,8 +2335,9 @@ function createFormController(options2) {
|
|
|
2019
2335
|
}
|
|
2020
2336
|
}
|
|
2021
2337
|
if (client && formId) {
|
|
2022
|
-
const visitorKey = form.settings.
|
|
2338
|
+
const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
|
|
2023
2339
|
const submissionKey = visitorKey ?? idempotencyKey;
|
|
2340
|
+
const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
|
|
2024
2341
|
status = "submitting";
|
|
2025
2342
|
submitError = void 0;
|
|
2026
2343
|
notify();
|
|
@@ -2030,7 +2347,9 @@ function createFormController(options2) {
|
|
|
2030
2347
|
hp: options2.getHoneypot?.() ?? "",
|
|
2031
2348
|
elapsedMs: Date.now() - mountedAt,
|
|
2032
2349
|
surface: options2.surface ?? "headless",
|
|
2033
|
-
submissionKey
|
|
2350
|
+
submissionKey,
|
|
2351
|
+
draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
|
|
2352
|
+
respondent
|
|
2034
2353
|
});
|
|
2035
2354
|
} catch (err) {
|
|
2036
2355
|
status = "idle";
|
|
@@ -2045,7 +2364,8 @@ function createFormController(options2) {
|
|
|
2045
2364
|
return;
|
|
2046
2365
|
}
|
|
2047
2366
|
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
2048
|
-
if (visitorKey) markSubmitted(formId, res.responseId);
|
|
2367
|
+
if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
|
|
2368
|
+
discardDraftState();
|
|
2049
2369
|
status = "submitted";
|
|
2050
2370
|
notify();
|
|
2051
2371
|
emitSubmitted(res.responseId, result.data);
|
|
@@ -2057,7 +2377,8 @@ function createFormController(options2) {
|
|
|
2057
2377
|
emitSubmitted(void 0, result.data);
|
|
2058
2378
|
return;
|
|
2059
2379
|
}
|
|
2060
|
-
|
|
2380
|
+
if (client && !formId) warnNoFormId();
|
|
2381
|
+
else warnNoClient();
|
|
2061
2382
|
status = "error";
|
|
2062
2383
|
notify();
|
|
2063
2384
|
}
|
|
@@ -2077,15 +2398,47 @@ function createFormController(options2) {
|
|
|
2077
2398
|
setContext(ctx) {
|
|
2078
2399
|
if ("formId" in ctx) formId = ctx.formId;
|
|
2079
2400
|
if ("client" in ctx) client = ctx.client;
|
|
2401
|
+
if ("respondent" in ctx) respondent = ctx.respondent;
|
|
2080
2402
|
if (ctx.form && ctx.form !== form) {
|
|
2081
2403
|
const next2 = normalizeFormSchema(ctx.form);
|
|
2082
2404
|
if (!next2.ok) throw new Error(`Invalid form schema: ${next2.error}`);
|
|
2083
2405
|
form = next2.schema;
|
|
2084
2406
|
}
|
|
2085
|
-
if (status === "idle" && hasSubmittedOnce(form, formId))
|
|
2407
|
+
if (status === "idle" && hasSubmittedOnce(form, formId, data)) {
|
|
2408
|
+
status = "submitted";
|
|
2409
|
+
restoredSubmission = true;
|
|
2410
|
+
}
|
|
2411
|
+
maybeRestoreDraft();
|
|
2412
|
+
notify();
|
|
2413
|
+
},
|
|
2414
|
+
flushDraft() {
|
|
2415
|
+
persistDraft(true);
|
|
2416
|
+
},
|
|
2417
|
+
resetDraft() {
|
|
2418
|
+
if (status === "submitting") return;
|
|
2419
|
+
const ref = draftRef ?? (formId ? readDraftRef(formId) : null);
|
|
2420
|
+
discardDraftState();
|
|
2421
|
+
if (ref && client) void client.deleteDraft(ref.id, ref.token).catch(() => {
|
|
2422
|
+
});
|
|
2423
|
+
data = { ...options2.initialData ?? {} };
|
|
2424
|
+
if (typeof location !== "undefined" && location.search) {
|
|
2425
|
+
const params = Object.fromEntries(new URLSearchParams(location.search));
|
|
2426
|
+
for (const [key, value] of Object.entries(prefillFromParams(form, params))) {
|
|
2427
|
+
if (data[key] === void 0) data[key] = value;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
errors = {};
|
|
2431
|
+
pageIndex = 0;
|
|
2432
|
+
submitError = void 0;
|
|
2433
|
+
resumedDraft = false;
|
|
2434
|
+
editingPrevious = false;
|
|
2086
2435
|
notify();
|
|
2087
2436
|
},
|
|
2088
2437
|
destroy() {
|
|
2438
|
+
if (draftTimer) {
|
|
2439
|
+
clearTimeout(draftTimer);
|
|
2440
|
+
draftTimer = null;
|
|
2441
|
+
}
|
|
2089
2442
|
listeners.clear();
|
|
2090
2443
|
}
|
|
2091
2444
|
};
|
|
@@ -2146,7 +2499,8 @@ var FILLO_SLOTS = [
|
|
|
2146
2499
|
"error",
|
|
2147
2500
|
"footer",
|
|
2148
2501
|
"button",
|
|
2149
|
-
"success"
|
|
2502
|
+
"success",
|
|
2503
|
+
"resume"
|
|
2150
2504
|
];
|
|
2151
2505
|
var FILLO_DATA_ATTRS = {
|
|
2152
2506
|
/** Slot name, on every slot element: `data-fillo="control"`. */
|
|
@@ -2157,6 +2511,8 @@ var FILLO_DATA_ATTRS = {
|
|
|
2157
2511
|
field: "data-field",
|
|
2158
2512
|
invalid: "data-invalid",
|
|
2159
2513
|
required: "data-required",
|
|
2514
|
+
/** Option id, on each choice row (pre-existing selector hook). */
|
|
2515
|
+
option: "data-option",
|
|
2160
2516
|
/** Selected option row / active scale step / active star. */
|
|
2161
2517
|
selected: "data-selected",
|
|
2162
2518
|
/** Checkbox / toggle checked state. */
|
|
@@ -2180,6 +2536,18 @@ function slotClass(base, appearance, state) {
|
|
|
2180
2536
|
const extra = resolveSlotClass(appearance, state);
|
|
2181
2537
|
return extra ? `${base} ${extra}` : base;
|
|
2182
2538
|
}
|
|
2539
|
+
var FILLO_THEME_VARS = [
|
|
2540
|
+
{ var: "--fillo-primary", themeProp: "primary" },
|
|
2541
|
+
{ var: "--fillo-bg", themeProp: "background" },
|
|
2542
|
+
{ var: "--fillo-text", themeProp: "text" },
|
|
2543
|
+
{ var: "--fillo-radius", themeProp: "radius" },
|
|
2544
|
+
{ var: "--fillo-font", themeProp: "fontFamily" },
|
|
2545
|
+
{ var: "--fillo-muted" },
|
|
2546
|
+
{ var: "--fillo-border" },
|
|
2547
|
+
{ var: "--fillo-control-bg" },
|
|
2548
|
+
{ var: "--fillo-error" },
|
|
2549
|
+
{ var: "--fillo-primary-contrast" }
|
|
2550
|
+
];
|
|
2183
2551
|
|
|
2184
2552
|
// src/strings.ts
|
|
2185
2553
|
var DEFAULT_STRINGS = {
|
|
@@ -2201,7 +2569,10 @@ var DEFAULT_STRINGS = {
|
|
|
2201
2569
|
loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
|
|
2202
2570
|
loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
|
|
2203
2571
|
loadFailed: "This form could not be loaded.",
|
|
2204
|
-
renderFailed: "This form could not be rendered."
|
|
2572
|
+
renderFailed: "This form could not be rendered.",
|
|
2573
|
+
resumeNotice: "Picked up where you left off.",
|
|
2574
|
+
editNotice: "You're updating your earlier response.",
|
|
2575
|
+
resumeStartOver: "Start over"
|
|
2205
2576
|
};
|
|
2206
2577
|
function resolveStrings(overrides) {
|
|
2207
2578
|
return overrides ? { ...DEFAULT_STRINGS, ...overrides } : DEFAULT_STRINGS;
|
|
@@ -2634,6 +3005,7 @@ export {
|
|
|
2634
3005
|
FILLO_SCHEMA_VERSION,
|
|
2635
3006
|
FILLO_SDK_VERSION,
|
|
2636
3007
|
FILLO_SLOTS,
|
|
3008
|
+
FILLO_THEME_VARS,
|
|
2637
3009
|
FilloClient,
|
|
2638
3010
|
FilloError,
|
|
2639
3011
|
FilloJsxError,
|
|
@@ -2667,6 +3039,7 @@ export {
|
|
|
2667
3039
|
needsExplicitSubmit,
|
|
2668
3040
|
normalizeFormSchema,
|
|
2669
3041
|
normalizeFormTheme,
|
|
3042
|
+
normalizeSettings,
|
|
2670
3043
|
parsePhone,
|
|
2671
3044
|
pipeBlock,
|
|
2672
3045
|
prefillFromParams,
|
|
@@ -2674,6 +3047,7 @@ export {
|
|
|
2674
3047
|
resolveSlotClass,
|
|
2675
3048
|
resolveStrings,
|
|
2676
3049
|
resolveText,
|
|
3050
|
+
responseScopeValue,
|
|
2677
3051
|
schemaFromJsx,
|
|
2678
3052
|
sha1Base64,
|
|
2679
3053
|
shouldAutoSubmit,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usefillo/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"typescript": "^5.8.3"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"build": "tsup
|
|
39
|
+
"build": "tsup",
|
|
40
40
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
42
|
"test": "node --test test/*.test.mjs"
|