@usefillo/core 0.6.2 → 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 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 `submissionLimit: "once_per_visitor"` for browser-scoped duplicate prevention. Themes include `colorScheme: "light" | "dark" | "auto"` for renderer defaults.
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
- * Default "multiple": a browser can submit as often as it wants. Use
165
- * "once_per_visitor" for tiny docs/article feedback where the SDK should
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
 
@@ -302,6 +376,7 @@ declare function validateResponse(form: FormSchema, data: ResponseData): Validat
302
376
  declare const FILLO_SCHEMA_VERSION: 1;
303
377
  /** Injected from package.json at build time (tsup define) — never hand-edited. */
304
378
  declare const FILLO_SDK_VERSION: string;
379
+ declare function normalizeSettings(value: unknown): FormSettings;
305
380
  interface SchemaValidationResult {
306
381
  ok: boolean;
307
382
  /** Present when ok — a normalized, structurally-valid schema. */
@@ -478,6 +553,8 @@ type SubmitResult = {
478
553
  responseId: string;
479
554
  /** True when the API accepted the request as an already-recorded visitor response. */
480
555
  duplicate?: boolean;
556
+ /** True when an update-in-place limit (responseLimit onRepeat "update") updated the person's living response. */
557
+ updated?: boolean;
481
558
  errors?: undefined;
482
559
  } | {
483
560
  ok: false;
@@ -496,9 +573,74 @@ interface SubmitMeta {
496
573
  surface?: "default" | "headless";
497
574
  /**
498
575
  * Browser-scoped de-duplication key sent when a form opts into
499
- * settings.submissionLimit = "once_per_visitor".
576
+ * settings.responseLimit.by = "browser".
500
577
  */
501
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;
502
644
  }
503
645
  interface UploadProgress {
504
646
  uploadedBytes: number;
@@ -553,6 +695,16 @@ declare class FilloClient {
553
695
  syncForm(handle: string, schema: FormSchema, theme?: FormTheme | null): Promise<SyncFormResult>;
554
696
  /** Submit a response. Returns per-field errors instead of throwing on validation failure. */
555
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>;
556
708
  /**
557
709
  * Open a respondent session for funnel analysis. Fire-and-forget: returns the
558
710
  * session id, or null if tracking is unavailable (never blocks the form).
@@ -563,6 +715,33 @@ declare class FilloClient {
563
715
  furthestPage?: number;
564
716
  completed?: boolean;
565
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>;
566
745
  /** Current state of an upload session — used to resume after interruption. */
567
746
  getUploadSession(sessionId: string, token?: string): Promise<UploadSession>;
568
747
  /**
@@ -680,6 +859,13 @@ interface FormControllerOptions {
680
859
  * respondent can retry.
681
860
  */
682
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;
683
869
  }
684
870
  interface FormControllerState {
685
871
  data: ResponseData;
@@ -697,6 +883,27 @@ interface FormControllerState {
697
883
  uploading: boolean;
698
884
  /** Human-readable message for the last failed submit; cleared on edit/retry. */
699
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;
700
907
  }
701
908
  interface FormController {
702
909
  /** Stable snapshot — same reference until something changes (safe for useSyncExternalStore). */
@@ -721,7 +928,21 @@ interface FormController {
721
928
  form?: FormSchema;
722
929
  formId?: string;
723
930
  client?: FilloClient;
931
+ /** Late-bind identify() context — host sessions often resolve after mount. */
932
+ respondent?: FilloRespondent;
724
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;
725
946
  /** Drop all listeners. */
726
947
  destroy(): void;
727
948
  }
@@ -753,7 +974,7 @@ declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubm
753
974
  * (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
754
975
  * names are public API — renames are breaking; additions are minors.
755
976
  */
756
- 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"];
757
978
  type FilloSlot = (typeof FILLO_SLOTS)[number];
758
979
  /** data-* names emitted alongside the stable fillo-* classes. */
759
980
  declare const FILLO_DATA_ATTRS: {
@@ -870,6 +1091,12 @@ interface FilloStrings {
870
1091
  loadFailedNetwork: string;
871
1092
  loadFailed: string;
872
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;
873
1100
  }
874
1101
  declare const DEFAULT_STRINGS: FilloStrings;
875
1102
  declare function resolveStrings(overrides?: Partial<FilloStrings>): FilloStrings;
@@ -1036,4 +1263,4 @@ declare class Sha1 {
1036
1263
  }
1037
1264
  declare const sha1Base64: (bytes: Uint8Array) => string;
1038
1265
 
1039
- 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, FILLO_THEME_VARS, 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 = true ? "0.6.2" : "0.0.0-dev";
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
- submissionLimit,
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(
@@ -1842,25 +1923,36 @@ function randomSubmissionKey() {
1842
1923
  }
1843
1924
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1844
1925
  }
1845
- function readStoredSubmission(formId) {
1926
+ function readStoredJson(memory, storageKey2, memoryKey, isValid) {
1846
1927
  const store = browserStorage();
1847
- if (!store) return memorySubmissionKeys.get(formId) ?? null;
1928
+ if (!store) return memory.get(memoryKey) ?? null;
1848
1929
  try {
1849
- const parsed = JSON.parse(store.getItem(submissionStorageKey(formId)) ?? "null");
1850
- if (parsed && typeof parsed.key === "string") return parsed;
1930
+ const parsed = JSON.parse(store.getItem(storageKey2) ?? "null");
1931
+ if (parsed && isValid(parsed)) return parsed;
1851
1932
  } catch {
1852
1933
  }
1853
1934
  return null;
1854
1935
  }
1855
- function writeStoredSubmission(formId, value) {
1856
- memorySubmissionKeys.set(formId, value);
1936
+ function writeStoredJson(memory, storageKey2, memoryKey, value) {
1937
+ memory.set(memoryKey, value);
1857
1938
  const store = browserStorage();
1858
1939
  if (!store) return;
1859
1940
  try {
1860
- store.setItem(submissionStorageKey(formId), JSON.stringify(value));
1941
+ store.setItem(storageKey2, JSON.stringify(value));
1861
1942
  } catch {
1862
1943
  }
1863
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
+ }
1864
1956
  function ensureSubmissionKey(formId) {
1865
1957
  const existing = readStoredSubmission(formId);
1866
1958
  if (existing?.key) return existing.key;
@@ -1876,10 +1968,40 @@ function markSubmitted(formId, responseId) {
1876
1968
  responseId
1877
1969
  });
1878
1970
  }
1879
- function hasSubmittedOnce(form, formId) {
1880
- if (form.settings.submissionLimit !== "once_per_visitor" || !formId) return false;
1881
- return Boolean(readStoredSubmission(formId)?.submittedAt);
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);
1882
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);
2003
+ }
2004
+ var DRAFT_DEBOUNCE_MS = 1500;
1883
2005
  function submitFailureMessage(err) {
1884
2006
  if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
1885
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.";
@@ -1891,15 +2013,13 @@ function createFormController(options2) {
1891
2013
  let form = initial.schema;
1892
2014
  let formId = options2.formId;
1893
2015
  let client = options2.client;
2016
+ let respondent = options2.respondent;
1894
2017
  let data = { ...options2.initialData ?? {} };
1895
2018
  let errors = {};
1896
2019
  let pageIndex = 0;
1897
2020
  let status = "idle";
2021
+ let restoredSubmission = false;
1898
2022
  queueMicrotask(() => {
1899
- if (status === "idle" && hasSubmittedOnce(form, formId)) {
1900
- status = "submitted";
1901
- notify();
1902
- }
1903
2023
  if (typeof location !== "undefined" && location.search) {
1904
2024
  const params = Object.fromEntries(new URLSearchParams(location.search));
1905
2025
  let merged = null;
@@ -1914,6 +2034,31 @@ function createFormController(options2) {
1914
2034
  notify();
1915
2035
  }
1916
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();
1917
2062
  });
1918
2063
  let submitError;
1919
2064
  const uploadingFields = /* @__PURE__ */ new Set();
@@ -1922,6 +2067,16 @@ function createFormController(options2) {
1922
2067
  const idempotencyKey = randomSubmissionKey();
1923
2068
  let sessionId = null;
1924
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;
1925
2080
  function computeSnapshot() {
1926
2081
  const pageCount = form.pages.length;
1927
2082
  const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
@@ -1937,7 +2092,10 @@ function createFormController(options2) {
1937
2092
  isFirstPage: clamped === 0,
1938
2093
  isLastPage: clamped === pageCount - 1,
1939
2094
  uploading: uploadingFields.size > 0,
1940
- submitError
2095
+ submitError,
2096
+ restoredSubmission,
2097
+ resumedDraft,
2098
+ editingPrevious
1941
2099
  };
1942
2100
  }
1943
2101
  let snapshot = computeSnapshot();
@@ -1963,6 +2121,153 @@ function createFormController(options2) {
1963
2121
  }
1964
2122
  return pageErrors;
1965
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
+ }
1966
2271
  function setValue(fieldId, value) {
1967
2272
  if (!sessionStarted && client && formId) {
1968
2273
  sessionStarted = true;
@@ -1975,6 +2280,7 @@ function createFormController(options2) {
1975
2280
  errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
1976
2281
  }
1977
2282
  notify();
2283
+ scheduleDraftSave();
1978
2284
  }
1979
2285
  function setUploading(fieldId, busy) {
1980
2286
  if (busy) uploadingFields.add(fieldId);
@@ -1995,11 +2301,13 @@ function createFormController(options2) {
1995
2301
  pageIndex = Math.min(clamped + 1, pageCount - 1);
1996
2302
  if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
1997
2303
  notify();
2304
+ checkpointDraft();
1998
2305
  }
1999
2306
  function back() {
2000
2307
  errors = {};
2001
2308
  pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
2002
2309
  notify();
2310
+ checkpointDraft();
2003
2311
  }
2004
2312
  async function submit() {
2005
2313
  if (status === "submitting" || status === "submitted") return;
@@ -2027,8 +2335,9 @@ function createFormController(options2) {
2027
2335
  }
2028
2336
  }
2029
2337
  if (client && formId) {
2030
- const visitorKey = form.settings.submissionLimit === "once_per_visitor" ? ensureSubmissionKey(formId) : void 0;
2338
+ const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
2031
2339
  const submissionKey = visitorKey ?? idempotencyKey;
2340
+ const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
2032
2341
  status = "submitting";
2033
2342
  submitError = void 0;
2034
2343
  notify();
@@ -2038,7 +2347,9 @@ function createFormController(options2) {
2038
2347
  hp: options2.getHoneypot?.() ?? "",
2039
2348
  elapsedMs: Date.now() - mountedAt,
2040
2349
  surface: options2.surface ?? "headless",
2041
- submissionKey
2350
+ submissionKey,
2351
+ draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
2352
+ respondent
2042
2353
  });
2043
2354
  } catch (err) {
2044
2355
  status = "idle";
@@ -2053,7 +2364,8 @@ function createFormController(options2) {
2053
2364
  return;
2054
2365
  }
2055
2366
  if (sessionId) client.reportProgress(sessionId, { completed: true });
2056
- if (visitorKey) markSubmitted(formId, res.responseId);
2367
+ if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
2368
+ discardDraftState();
2057
2369
  status = "submitted";
2058
2370
  notify();
2059
2371
  emitSubmitted(res.responseId, result.data);
@@ -2086,15 +2398,47 @@ function createFormController(options2) {
2086
2398
  setContext(ctx) {
2087
2399
  if ("formId" in ctx) formId = ctx.formId;
2088
2400
  if ("client" in ctx) client = ctx.client;
2401
+ if ("respondent" in ctx) respondent = ctx.respondent;
2089
2402
  if (ctx.form && ctx.form !== form) {
2090
2403
  const next2 = normalizeFormSchema(ctx.form);
2091
2404
  if (!next2.ok) throw new Error(`Invalid form schema: ${next2.error}`);
2092
2405
  form = next2.schema;
2093
2406
  }
2094
- if (status === "idle" && hasSubmittedOnce(form, formId)) status = "submitted";
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;
2095
2435
  notify();
2096
2436
  },
2097
2437
  destroy() {
2438
+ if (draftTimer) {
2439
+ clearTimeout(draftTimer);
2440
+ draftTimer = null;
2441
+ }
2098
2442
  listeners.clear();
2099
2443
  }
2100
2444
  };
@@ -2155,7 +2499,8 @@ var FILLO_SLOTS = [
2155
2499
  "error",
2156
2500
  "footer",
2157
2501
  "button",
2158
- "success"
2502
+ "success",
2503
+ "resume"
2159
2504
  ];
2160
2505
  var FILLO_DATA_ATTRS = {
2161
2506
  /** Slot name, on every slot element: `data-fillo="control"`. */
@@ -2224,7 +2569,10 @@ var DEFAULT_STRINGS = {
2224
2569
  loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
2225
2570
  loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
2226
2571
  loadFailed: "This form could not be loaded.",
2227
- 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"
2228
2576
  };
2229
2577
  function resolveStrings(overrides) {
2230
2578
  return overrides ? { ...DEFAULT_STRINGS, ...overrides } : DEFAULT_STRINGS;
@@ -2691,6 +3039,7 @@ export {
2691
3039
  needsExplicitSubmit,
2692
3040
  normalizeFormSchema,
2693
3041
  normalizeFormTheme,
3042
+ normalizeSettings,
2694
3043
  parsePhone,
2695
3044
  pipeBlock,
2696
3045
  prefillFromParams,
@@ -2698,6 +3047,7 @@ export {
2698
3047
  resolveSlotClass,
2699
3048
  resolveStrings,
2700
3049
  resolveText,
3050
+ responseScopeValue,
2701
3051
  schemaFromJsx,
2702
3052
  sha1Base64,
2703
3053
  shouldAutoSubmit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.6.2",
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": [