@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 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
 
@@ -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
- declare const FILLO_SDK_VERSION = "0.5.0";
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.submissionLimit = "once_per_visitor".
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.5.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
- 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(
@@ -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 readStoredSubmission(formId) {
1926
+ function readStoredJson(memory, storageKey2, memoryKey, isValid) {
1838
1927
  const store = browserStorage();
1839
- if (!store) return memorySubmissionKeys.get(formId) ?? null;
1928
+ if (!store) return memory.get(memoryKey) ?? null;
1840
1929
  try {
1841
- const parsed = JSON.parse(store.getItem(submissionStorageKey(formId)) ?? "null");
1842
- if (parsed && typeof parsed.key === "string") return 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 writeStoredSubmission(formId, value) {
1848
- memorySubmissionKeys.set(formId, value);
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(submissionStorageKey(formId), JSON.stringify(value));
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 hasSubmittedOnce(form, formId) {
1872
- if (form.settings.submissionLimit !== "once_per_visitor" || !formId) return false;
1873
- 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);
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.submissionLimit === "once_per_visitor" ? ensureSubmissionKey(formId) : void 0;
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
- warnNoClient();
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)) 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;
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.6.1",
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 src/index.ts --format esm --dts --clean",
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"