@parag.more.withsurface.com/forms-sdk 0.1.5 → 0.1.6

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.
Files changed (106) hide show
  1. package/README.md +9 -4
  2. package/dist/index.cjs +1 -2736
  3. package/dist/index.mjs +1 -2713
  4. package/dist/react/index.cjs +1 -43
  5. package/dist/react/index.mjs +1 -20
  6. package/dist/surface-forms-sdk.min.js +21 -1
  7. package/dist/types/bind/bindForm.d.ts +2 -0
  8. package/dist/types/bind/bookingWidget.d.ts +29 -6
  9. package/dist/types/bind/scheduler/embedUtils.d.ts +50 -0
  10. package/dist/types/bind/scheduler/mountScheduler.d.ts +2 -0
  11. package/dist/types/bind/scheduler/providers/cal.d.ts +3 -0
  12. package/dist/types/bind/scheduler/providers/calendly.d.ts +3 -0
  13. package/dist/types/bind/scheduler/providers/chilical.d.ts +3 -0
  14. package/dist/types/bind/scheduler/providers/clari.d.ts +3 -0
  15. package/dist/types/bind/scheduler/providers/hubspot.d.ts +3 -0
  16. package/dist/types/bind/scheduler/providers/index.d.ts +4 -0
  17. package/dist/types/bind/scheduler/providers/reclaimai.d.ts +3 -0
  18. package/dist/types/bind/scheduler/providers/revenuehero.d.ts +3 -0
  19. package/dist/types/bind/scheduler/providers/savvycal.d.ts +3 -0
  20. package/dist/types/bind/scheduler/providers/surface.d.ts +3 -0
  21. package/dist/types/bind/scheduler/providers/zoom.d.ts +3 -0
  22. package/dist/types/bind/scheduler/styles.d.ts +3 -0
  23. package/dist/types/bind/scheduler/types.d.ts +65 -0
  24. package/dist/types/core/client/emailValidation.d.ts +3 -0
  25. package/dist/types/core/client/responses.d.ts +3 -0
  26. package/dist/types/core/client/runtimeConfig.d.ts +40 -0
  27. package/dist/types/core/client/scheduler.d.ts +4 -0
  28. package/dist/types/core/client/transport.d.ts +4 -0
  29. package/dist/types/core/engine/answerAudit.d.ts +3 -0
  30. package/dist/types/core/engine/answerStates.d.ts +4 -0
  31. package/dist/types/core/engine/formEngine.d.ts +16 -0
  32. package/dist/types/core/engine/redirect.d.ts +1 -0
  33. package/dist/types/core/engine/responsePayload.d.ts +4 -0
  34. package/dist/types/core/engine/schedulerEvents.d.ts +40 -0
  35. package/dist/types/core/engine/urlData.d.ts +2 -0
  36. package/dist/types/core/engine/urlPrefill.d.ts +1 -0
  37. package/dist/types/core/events/dropoffDetection.d.ts +1 -0
  38. package/dist/types/core/events/emitter.d.ts +1 -0
  39. package/dist/types/core/events/formJourneyTracker.d.ts +1 -0
  40. package/dist/types/core/events/pushEvents.d.ts +2 -0
  41. package/dist/types/core/events/startedDetection.d.ts +1 -0
  42. package/dist/types/core/events/viewDetection.d.ts +1 -0
  43. package/dist/types/core/identity/browserFingerprint.d.ts +3 -0
  44. package/dist/types/core/identity/ensureIdentified.d.ts +1 -0
  45. package/dist/types/core/identity/identify.d.ts +1 -0
  46. package/dist/types/core/identity/journey.d.ts +3 -0
  47. package/dist/types/core/identity/journey.testStubs.d.ts +1 -0
  48. package/dist/types/core/identity/leadDataCache.d.ts +1 -0
  49. package/dist/types/enrichment/attachEnrichment.d.ts +1 -0
  50. package/dist/types/index.d.ts +5 -2
  51. package/dist/types/react/useSurfaceForm.d.ts +1 -0
  52. package/dist/types/surfaceForms.d.ts +28 -1
  53. package/dist/types/tracking/attachTracking.d.ts +1 -0
  54. package/dist/types/tracking/conversionRules.d.ts +3 -0
  55. package/dist/types/tracking/vendors.d.ts +3 -0
  56. package/docs/api.md +51 -39
  57. package/docs/reference/binding.md +86 -13
  58. package/docs/reference/clients.md +21 -1
  59. package/docs/reference/engine.md +5 -1
  60. package/docs/reference/events.md +4 -1
  61. package/llms.txt +58 -10
  62. package/package.json +1 -1
  63. package/skills/surface-forms-sdk/SKILL.md +19 -6
  64. package/skills/surface-forms-sdk/patterns.md +30 -5
  65. package/dist/types/bind/bindForm.test.d.ts +0 -1
  66. package/dist/types/bind/bookingWidget.test.d.ts +0 -1
  67. package/dist/types/core/client/emailValidation.test.d.ts +0 -1
  68. package/dist/types/core/client/responses.test.d.ts +0 -1
  69. package/dist/types/core/client/runtimeConfig.contract.test.d.ts +0 -1
  70. package/dist/types/core/client/runtimeConfig.test.d.ts +0 -1
  71. package/dist/types/core/client/scheduler.contract.test.d.ts +0 -1
  72. package/dist/types/core/client/scheduler.test.d.ts +0 -1
  73. package/dist/types/core/client/transport.test.d.ts +0 -1
  74. package/dist/types/core/engine/answerAudit.test.d.ts +0 -1
  75. package/dist/types/core/engine/answerStates.contract.test.d.ts +0 -1
  76. package/dist/types/core/engine/formEngine.test.d.ts +0 -1
  77. package/dist/types/core/engine/redirect.test.d.ts +0 -1
  78. package/dist/types/core/engine/responsePayload.contract.test.d.ts +0 -1
  79. package/dist/types/core/engine/responsePayload.test.d.ts +0 -1
  80. package/dist/types/core/engine/urlData.contract.test.d.ts +0 -1
  81. package/dist/types/core/engine/urlData.test.d.ts +0 -1
  82. package/dist/types/core/engine/urlPrefill.test.d.ts +0 -1
  83. package/dist/types/core/events/dropoffDetection.test.d.ts +0 -1
  84. package/dist/types/core/events/emitter.test.d.ts +0 -1
  85. package/dist/types/core/events/formJourneyTracker.contract.test.d.ts +0 -1
  86. package/dist/types/core/events/formJourneyTracker.test.d.ts +0 -1
  87. package/dist/types/core/events/pushEvents.contract.test.d.ts +0 -1
  88. package/dist/types/core/events/pushEvents.test.d.ts +0 -1
  89. package/dist/types/core/events/startedDetection.test.d.ts +0 -1
  90. package/dist/types/core/events/viewDetection.test.d.ts +0 -1
  91. package/dist/types/core/identity/browserFingerprint.test.d.ts +0 -1
  92. package/dist/types/core/identity/ensureIdentified.test.d.ts +0 -1
  93. package/dist/types/core/identity/identify.contract.test.d.ts +0 -1
  94. package/dist/types/core/identity/identify.test.d.ts +0 -1
  95. package/dist/types/core/identity/journey.contract.test.d.ts +0 -1
  96. package/dist/types/core/identity/journey.test.d.ts +0 -1
  97. package/dist/types/core/identity/leadDataCache.test.d.ts +0 -1
  98. package/dist/types/enrichment/attachEnrichment.test.d.ts +0 -1
  99. package/dist/types/react/ssr.test.d.ts +0 -1
  100. package/dist/types/react/useSurfaceForm.test.d.ts +0 -1
  101. package/dist/types/surfaceForms.test.d.ts +0 -1
  102. package/dist/types/tracking/attachTracking.test.d.ts +0 -1
  103. package/dist/types/tracking/conversionRules.contract.test.d.ts +0 -1
  104. package/dist/types/tracking/conversionRules.test.d.ts +0 -1
  105. package/dist/types/tracking/vendors.test.d.ts +0 -1
  106. package/dist/types/validation/attachEmailValidation.test.d.ts +0 -1
@@ -1,3 +1,4 @@
1
+
1
2
  export type LifecycleUrlData = {
2
3
  url: string;
3
4
  urlParams: Record<string, string>;
@@ -11,11 +12,13 @@ export type VendorSettings = {
11
12
  };
12
13
  };
13
14
  export type StepSubmittedPayload = LifecycleUrlData & {
15
+
14
16
  stepId: string;
15
17
  formId: string;
16
18
  emailProvided: boolean;
17
19
  meetingBooked: boolean;
18
20
  };
21
+
19
22
  export declare const createVendorTracker: (win: Window, settings: VendorSettings) => {
20
23
  formStarted: (formId: string, urlData: LifecycleUrlData) => void;
21
24
  stepSubmitted: (payload: StepSubmittedPayload) => void;
package/docs/api.md CHANGED
@@ -35,25 +35,25 @@ exact URL and host it tried.
35
35
 
36
36
  ### `SurfaceFormsInitOptions`
37
37
 
38
- | Option | Type | Meaning |
39
- | --------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40
- | `environmentId` | `string` | Required. From `get_sdk_binding_map`. |
41
- | `formId` | `string` | Required. |
42
- | `apiBaseUrl` | `string?` | The environment's host. **Required unless the environment is on `https://forms.withsurface.com`** — `get_sdk_binding_map` returns it. |
43
- | `container` | `Element?` | Enables the `data-*` binding layer + view detection on this root (§4). Omit for fully headless use. |
44
- | `steps` | `Array<{id, endStepKind?}>?` | Caller-provided step order — **skips the record fetch entirely** (offline mode). Normally omitted. |
45
- | `settings` | object? | Behavioral gates + tracking config (`trackPartialResponses`, `trackCookies`, `gtmId`, `gaMeasurementId`, `metaPixelId`, `hubspotTracking`, `adsConversions`). Caller values win over the record's. |
46
- | `expectedQuestionIds` | `string[]?` | Overrides the record-derived list behind the unbound-question console warning. |
47
- | `resolveNextStep` | `ResolveNextStep?` | Routing hook for `next()`/`submit()` (§7.3); defaults to linear record order. |
48
- | `urlDataStateId` | `string?` | Seeds a `URL_DATA` attribution pseudo-answer under this id (rides every save). |
49
- | `source` | `LeadSource?` | Lead source for identify; defaults `"sdkForm"`. |
50
- | `journey` | `boolean?` | Page-view tracking module (§7.6), default off. |
51
- | `emailValidation` | `boolean?` | Automatic waterfall email validation (§5), default off — verdicts bill the form owner's validator quota. |
52
- | `preview` | `boolean?` | Blocks ALL network writes (identify, responses, events, journey, enrichment, tracking). Scheduler returns synthetic bookings. |
53
- | `previewToken` | `string?` | With `preview: true`, serves a never-published form's DRAFT config. From `create_form` / `get_sdk_binding_map`. |
54
- | `resume` | `{responseId, resumeToken}?` | Continue a stored response (§3). Ignored in preview. |
55
- | `debug` | `boolean?` | Logs lifecycle + audits every answer against the record; warns on states the server would reject and on inert config (declared enrichment source, no provider). Dev only. |
56
- | `meta` | `Record<string, unknown>?` | Seed response meta. `surfaceTagStatus` is always overwritten to `"sdk"`. |
38
+ | Option | Type | Meaning |
39
+ | --------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | `environmentId` | `string` | Required. From `get_sdk_binding_map`. |
41
+ | `formId` | `string` | Required. |
42
+ | `apiBaseUrl` | `string?` | The environment's host. **Required unless the environment is on `https://forms.withsurface.com`** — `get_sdk_binding_map` returns it. |
43
+ | `container` | `Element?` | Enables the `data-*` binding layer + view detection on this root (§4). Omit for fully headless use. |
44
+ | `steps` | `Array<{id, endStepKind?}>?` | Caller-provided step order — **skips the record fetch entirely** (offline mode). Normally omitted. |
45
+ | `settings` | object? | Behavioral gates + tracking config (`trackPartialResponses`, `trackCookies`, `gtmId`, `gaMeasurementId`, `metaPixelId`, `hubspotTracking`, `adsConversions`). Caller values win over the record's. |
46
+ | `expectedQuestionIds` | `string[]?` | Overrides the record-derived list behind the unbound-question console warning. |
47
+ | `resolveNextStep` | `ResolveNextStep?` | Routing hook for `next()`/`submit()` (§7.3); defaults to linear record order. |
48
+ | `urlDataStateId` | `string?` | Seeds a `URL_DATA` attribution pseudo-answer under this id (rides every save). |
49
+ | `source` | `LeadSource?` | Lead source for identify; defaults `"sdkForm"`. |
50
+ | `journey` | `boolean?` | Page-view tracking module (§7.6), default off. |
51
+ | `emailValidation` | `boolean?` | Automatic waterfall email validation (§5), default off — verdicts bill the form owner's validator quota. |
52
+ | `preview` | `boolean?` | Blocks ALL network writes (identify, responses, events, journey, enrichment, tracking). Scheduler returns synthetic bookings. |
53
+ | `previewToken` | `string?` | With `preview: true`, serves a never-published form's DRAFT config. From `create_form` / `get_sdk_binding_map`. |
54
+ | `resume` | `{responseId, resumeToken}?` | Continue a stored response (§3). Ignored in preview. |
55
+ | `debug` | `boolean?` | Logs lifecycle + navigation intent (`next requested from step …`, distinguishing an un-fired click from a blocked advance) + audits every answer against the record; warns on states the server would reject and on inert config (declared enrichment source, no provider). Dev only. |
56
+ | `meta` | `Record<string, unknown>?` | Seed response meta. `surfaceTagStatus` is always overwritten to `"sdk"`. |
57
57
 
58
58
  ## 2. The form handle — `SurfaceForm`
59
59
 
@@ -61,26 +61,28 @@ Everything the engine exposes (§7.3) minus internals, plus init-level helpers.
61
61
  Full method semantics, event payloads, and delivery guarantees:
62
62
  [`reference/engine.md`](reference/engine.md).
63
63
 
64
- | Member | Signature | Semantics |
65
- | ------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
66
- | `setAnswer` | `(questionId, state) => void` | Merges a partial state into the question's answer. Use the typed answer states (§6) — wrong shapes are the classic production 400. |
67
- | `next` / `back` | `() => Promise<void>` / `() => void` | Linear advance (saves progress; the step being LEFT is stamped on the write) / rewind (never writes). |
68
- | `goToStep` | `(stepId) => Promise<void>` | Customer-owned routing: forward jumps save like `next()`; jumps to a visited step rewind like `back()`. Rejects unknown ids. |
69
- | `submit` | `() => Promise<void>` | Finishes the form. **The terminal write is awaited** — when this resolves (and `saved` fires with `finished: true`), the server has the finished response; a delivery failure emits `error` instead. Then reveals the record's `thank_you` step. |
70
- | `disqualify` | `() => Promise<void>` | Finishes as disqualified (`invalidResponse: true`); reveals the `disqualified` ending. |
71
- | `savePartial` | `() => Promise<void>` | Explicit mid-form save (fire-and-forget beacon delivery). |
72
- | `flush` | `() => Promise<void>` | Resolves when every queued write (including post-finish amends) has settled. Await before programmatic teardown. |
73
- | `markViewed` | `() => void` | Emits `form_viewed` + first-step `form_step_viewed`, once. The bind layer calls it via IntersectionObserver; headless callers call it themselves. |
74
- | `state` | `() => FormEngineState` | `{ currentStepId, answers, externalStates, responseId, resumeToken, finished }`. Referentially stable between mutations (safe for `useSyncExternalStore`). |
75
- | `subscribe` | `(cb) => Unsubscribe` | Store subscription (fires on any state change). |
76
- | `on` | `(event, cb) => Unsubscribe` | Lifecycle events: `viewed`, `started`, `stepChanged {fromStepId,toStepId}`, `stepCompleted {stepId}`, `completed {formId}`, `disqualified {stepId}`, `saved {responseId, finished, resumeToken}`, `error {scope, error}`. |
77
- | `setExternalState` | `(id, {type, ...}) => void` | Writes a pseudo-answer that rides every save (enrichment results, custom attribution). Post-finish it amends the stored response. |
78
- | `mergeMeta` | `(partial) => void` | Records caller-computed values (qualification score, experiment bucket) onto response `meta`; rides the next save or amends post-finish. Reserved SDK keys (`surfaceTagStatus`, email-validation verdicts) are dropped with a warning. |
79
- | `identify` | `() => Promise<unknown>` | Re-runs visitor identify (SPA route change); later writes attribute to the fresh session. |
80
- | `validateEmail` | `(email) => Promise<EmailValidationResult>` | On-demand deliverability verdict (§5). |
81
- | `capabilities` | `{enrichment, emailValidation, tracking}` | What actually runs in THIS environment — a record can declare features the environment doesn't back. |
82
- | `runtimeConfig` | `RuntimeConfig?` | The fetched record (undefined when `steps` was caller-provided). |
83
- | `destroy` | `() => void` | Tears down every module binding. One engine per page-life re-`init` without `destroy` creates duplicate responses. |
64
+ | Member | Signature | Semantics |
65
+ | ------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
66
+ | `setAnswer` | `(questionId, state) => void` | Merges a partial state into the question's answer. Use the typed answer states (§6) — wrong shapes are the classic production 400. |
67
+ | `next` / `back` | `() => Promise<void>` / `() => void` | Linear advance (saves progress; the step being LEFT is stamped on the write) / rewind (never writes). |
68
+ | `goToStep` | `(stepId) => Promise<void>` | Customer-owned routing: forward jumps save like `next()`; jumps to a visited step rewind like `back()`. Rejects unknown ids. |
69
+ | `submit` | `() => Promise<void>` | Finishes the form. **The terminal write is awaited** — when this resolves (and `saved` fires with `finished: true`), the server has the finished response; a delivery failure emits `error` instead. Then reveals the record's `thank_you` step. |
70
+ | `disqualify` | `() => Promise<void>` | Finishes as disqualified (`invalidResponse: true`); reveals the `disqualified` ending. |
71
+ | `savePartial` | `() => Promise<void>` | Explicit mid-form save (fire-and-forget beacon delivery). |
72
+ | `flush` | `() => Promise<void>` | Resolves when every queued write (including post-finish amends) has settled. Await before programmatic teardown. |
73
+ | `markViewed` | `() => void` | Emits `form_viewed` + first-step `form_step_viewed`, once. The bind layer calls it via IntersectionObserver; headless callers call it themselves. |
74
+ | `state` | `() => FormEngineState` | `{ currentStepId, answers, externalStates, responseId, resumeToken, finished }`. Referentially stable between mutations (safe for `useSyncExternalStore`). |
75
+ | `subscribe` | `(cb) => Unsubscribe` | Store subscription (fires on any state change). |
76
+ | `on` | `(event, cb) => Unsubscribe` | Lifecycle events: `viewed`, `started`, `stepChanged {fromStepId,toStepId}`, `stepCompleted {stepId}`, `completed {formId}`, `disqualified {stepId}`, `saved {responseId, finished, resumeToken}`, `error {scope, error}`, `scheduler` (`SchedulerLifecycleEvent`, §7.8). |
77
+ | `setExternalState` | `(id, {type, ...}) => void` | Writes a pseudo-answer that rides every save (enrichment results, custom attribution). Post-finish it amends the stored response. |
78
+ | `mergeMeta` | `(partial) => void` | Records caller-computed values (qualification score, experiment bucket) onto response `meta`; rides the next save or amends post-finish. Reserved SDK keys (`surfaceTagStatus`, email-validation verdicts) are dropped with a warning. |
79
+ | `identify` | `() => Promise<unknown>` | Re-runs visitor identify (SPA route change); later writes attribute to the fresh session. |
80
+ | `validateEmail` | `(email) => Promise<EmailValidationResult>` | On-demand deliverability verdict (§5). |
81
+ | `capabilities` | `{enrichment, emailValidation, tracking}` | What actually runs in THIS environment — a record can declare features the environment doesn't back. |
82
+ | `runtimeConfig` | `RuntimeConfig?` | The fetched record (undefined when `steps` was caller-provided). |
83
+ | `notifyScheduler` | `(event) => void` | Emits on the `scheduler` channel (used by `mountScheduler`; emit-only, never writes). |
84
+ | `transport` / `identity` / `preview` | advanced | The boot transport, the live identity getter, and the preview flag — for modules acting on the form's behalf (`mountScheduler` uses all three). |
85
+ | `destroy` | `() => void` | Tears down every module binding. One engine per page-life — re-`init` without `destroy` creates duplicate responses. |
84
86
 
85
87
  ## 3. Resume
86
88
 
@@ -268,6 +270,16 @@ true` page-view module (tag-compatible cookies, duplicate-URL skip, SPA
268
270
 
269
271
  Full `SchedulerClient` signatures and widget options: [`reference/clients.md`](reference/clients.md) + [`reference/binding.md`](reference/binding.md).
270
272
 
273
+ - **`mountScheduler(options): () => void`** — the DEFAULT scheduler path: one
274
+ call renders the record's scheduler question for ANY supported provider
275
+ (Surface's widget default-styled, or the third-party embed native forms
276
+ use), emits the `SchedulerLifecycleEvent` union on `form.on("scheduler")` +
277
+ `onEvent`, and auto-persists the booking into the response
278
+ (`persist: false` opts out). Options table, provider matrix, and
279
+ persistence semantics: [`reference/binding.md`](reference/binding.md).
280
+ - **`schedulerQuestionsOf(config): SchedulerQuestion[]`** — the record's
281
+ scheduler questions with provider + embed content
282
+ ([`reference/clients.md`](reference/clients.md)).
271
283
  - **`createSchedulerClient({ transport, preview? }): SchedulerClient`** —
272
284
  public Surface-scheduler endpoints: `getPublicEventType`,
273
285
  `getAvailabilitySummary`, `getMonthAvailability` (`YYYY-MM`), `getSlots`,
@@ -63,24 +63,97 @@ flash.)
63
63
  - `expectedQuestionIds` (init derives them from the record) minus the ids actually bound →
64
64
  a console warning naming the unbound ids; the runtime counterpart of `validate_form_html`.
65
65
 
66
+ ## `mountScheduler(options): () => void`
67
+
68
+ The DEFAULT scheduler path: renders the record's scheduler question for any supported provider,
69
+ emits the lifecycle on the form's `scheduler` event channel, and persists the booking into the
70
+ response automatically. Reach for `mountBookingWidget` (below) only when building a custom UI.
71
+
72
+ ### Options (`MountSchedulerOptions`)
73
+
74
+ | Option | Type | Semantics |
75
+ | ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
76
+ | `form` | `SurfaceForm` | The `init` handle — config, transport, identity, and persistence all come from it. |
77
+ | `container` | `Element` | The embed mounts here; teardown removes it. |
78
+ | `questionId` | `string?` | Required only when the record has >1 scheduler question (the error names the candidates). |
79
+ | `stepId` | `string?` | Alternative selector: the step carrying the scheduler question. |
80
+ | `styled` | `boolean?` (default `true`) | Injects a stylesheet scoped under `.surface-scheduler` on THIS container (Surface widget look + frame sizing). Override via `--surface-scheduler-*` custom props or the same class names; `false` keeps everything bare. |
81
+ | `persist` | `boolean?` (default `true`) | Auto-persist lifecycle + booking into the response. `false` still emits events but never writes (and never creates a response). |
82
+ | `prefill` | `{name?, lastName?, email?}?` | Overrides the prefill resolved from the record's linked answer refs (`linkNameToStep` etc.). |
83
+ | `formatters` | `{month?, date?, time?}?` | Surface provider only — forwarded to the booking widget. |
84
+ | `calendarId` | `string?` | DynamicScheduler only: pick a routed calendar by name. Without it: the default calendar, or a lone `calendars[0]`; ambiguous routing throws (conditions are your page's own routing). |
85
+ | `onEvent` | `(SchedulerLifecycleEvent) => void?` | Same events as `form.on("scheduler", …)`. |
86
+ | `onComplete` | `(SchedulerBookingSummary) => void?` | Fires alongside `booking_confirmed`. NOT needed for persistence — that's automatic. |
87
+
88
+ ### Lifecycle events (`SchedulerLifecycleEvent`)
89
+
90
+ Every event carries `{questionId, stepId, provider}` plus:
91
+
92
+ ```ts
93
+ | { type: "viewed" } // once per mount
94
+ | { type: "date_selected"; date? /* "YYYY-MM-DD" */ }
95
+ | { type: "slot_selected"; slot?: { start; end? } }
96
+ | { type: "form_opened" }
97
+ | { type: "booking_confirmed"; booking: SchedulerBookingSummary }
98
+ | { type: "booking_cancelled"; reason? }
99
+ ```
100
+
101
+ `SchedulerBookingSummary` = `{provider, uri, meetingTime?, meetingEndTime?, inviteeName?,
102
+ guestEmails?, ownerEmails?, joinLink?, raw?}` — `uri` is the provider-native booking id
103
+ (HubSpot's embed reports none; its uri is the single space native forms persist), `raw` the
104
+ untouched provider payload.
105
+
106
+ ### Providers
107
+
108
+ | Provider (record `calendarType`) | Embed | Booking signal | Extra lifecycle |
109
+ | -------------------------------- | -------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
110
+ | `Surface` | SDK booking widget (styled by default) | widget booking | `date_selected`, `slot_selected`, `form_opened`; sub-steps persist as `schedulerSubStep` |
111
+ | `Calendly` | iframe | postMessage + server-poll fallback | provider-reported `viewed`, `slot_selected`; poll-reported cancellation |
112
+ | `Cal` (Cal.com) | vendor script | `bookingSuccessful` CustomEvent | — |
113
+ | `SavvyCal` | vendor script | `savvycal.scheduled` CustomEvent | — |
114
+ | `ChiliCal` (Chili Piper) | iframe | `booking-confirmed` postMessage | — |
115
+ | `Hubspot` | vendor script | `meetingBookSucceeded` postMessage | — |
116
+ | `RevenueHero` | vendor script + router submit | `MEETING_BOOKED` postMessage | — |
117
+ | `Zoom` | iframe | `bookingForm` postMessage | server-poll backfills host email / times / join link into the answer |
118
+ | `ReclaimAI` | iframe | server poll ONLY (no client events) | poll carries the booking |
119
+ | `Clari` | iframe | **none** — embed-only; nothing is tracked or persisted | — |
120
+
121
+ postMessage listeners are origin-checked strictly (exact origin or anchored host suffix — the
122
+ SDK mirrors the vendor protocols, not the laxness some native checks have).
123
+
124
+ ### Persistence semantics
125
+
126
+ - Confirmed booking → the frozen scheduler answer keys (`eventScheduled`, `uri`,
127
+ `meetingTime`…, `type` = the record's component type) merged via `setAnswer`, then a partial
128
+ save; after `submit()` the engine amends the stored response instead. `meta.bookingId` (Surface
129
+ bookings only) and the `meeting_booked` ad-conversion trigger follow automatically.
130
+ - Poll-reported cancellation (after a confirmed booking) → `eventCancelled: true` + save.
131
+ - Calendly/Zoom/Reclaim embeds carry the `responseId` in their URL, so mounting them creates the
132
+ response with a partial save when none exists yet. The Surface widget resolves identity lazily
133
+ at book time instead — lookers who never book create nothing.
134
+ - `preview: true` on the form blocks every write and poll; the Surface widget books through the
135
+ fabricated preview client, so the whole flow demos offline.
136
+
66
137
  ## `mountBookingWidget(options): () => void`
67
138
 
68
- The unstyled Surface-scheduler booking flow (date → time → name/email form → booked) over a
69
- `SchedulerClient` ([`clients.md`](clients.md)). Never iframe the hosted booking page.
139
+ The custom-UI escape hatch: the unstyled Surface-scheduler booking flow (date → time →
140
+ name/email form → booked) over a `SchedulerClient` ([`clients.md`](clients.md)). Never iframe
141
+ the hosted booking page. For the zero-effort path use `mountScheduler` (above).
70
142
 
71
143
  ### Options (`BookingWidgetOptions`)
72
144
 
73
- | Option | Type | Semantics |
74
- | ------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
75
- | `container` | `Element` | The widget appends its own `div.surface-booking` root here; teardown removes it. |
76
- | `scheduler` | `SchedulerClient` | From `createSchedulerClient` (share the transport; pass `preview` there for synthetic bookings). |
77
- | `eventTypeId` | `string` | From the record's `CalendlyScreen` content. |
78
- | `timezone` | `string?` | Defaults to the visitor's (`Intl.DateTimeFormat().resolvedOptions().timeZone`); slot labels format in it. |
79
- | `duration` | `number?` | Threaded into availability/slots/book queries. |
80
- | `prefill` | `{name?, email?}?` | Pre-fills the widget's name/email inputs. |
81
- | `identity` | `{responseId?, leadSessionId?, fingerprint?}?` | Threaded into `book()` for booking↔response linkage. |
82
- | `formatters` | `{month?, date?, time?}?` | Override the copy: `month("YYYY-MM")`, `date("YYYY-MM-DD")`, `time(slot)` (ISO instants; format in the widget timezone). Defaults are locale-formatted via `Intl`. |
83
- | `onComplete` | `(booking: Booking) => void` | Fires after a successful booking persist via `setAnswer({...toSchedulerAnswerState(booking), type: "<record type>"})` and navigate. |
145
+ | Option | Type | Semantics |
146
+ | ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
147
+ | `container` | `Element` | The widget appends its own `div.surface-booking` root here; teardown removes it. |
148
+ | `scheduler` | `SchedulerClient` | From `createSchedulerClient` (share the transport; pass `preview` there for synthetic bookings). |
149
+ | `eventTypeId` | `string` | From the record's `CalendlyScreen` content. |
150
+ | `timezone` | `string?` | Defaults to the visitor's (`Intl.DateTimeFormat().resolvedOptions().timeZone`); slot labels format in it. |
151
+ | `duration` | `number?` | Threaded into availability/slots/book queries. |
152
+ | `prefill` | `{name?, email?}?` | Pre-fills the widget's name/email inputs. |
153
+ | `identity` | `BookingIdentity \| (() => BookingIdentity \| Promise<BookingIdentity>)?` | Threaded into `book()` for booking↔response linkage. The getter form resolves at book time — a response created after mount still links. |
154
+ | `formatters` | `{month?, date?, time?}?` | Override the copy: `month("YYYY-MM")`, `date("YYYY-MM-DD")`, `time(slot)` (ISO instants; format in the widget timezone). Defaults are locale-formatted via `Intl`. |
155
+ | `onEvent` | `(BookingWidgetEvent) => void?` | Sub-step transitions: `viewed`, `date_selected {date}`, `slot_selected {slot}`, `form_opened` (fresh opens only, not error re-renders), `booking_confirmed {booking}` (alongside `onComplete`). |
156
+ | `onComplete` | `(booking: Booking) => void` | Fires after a successful booking — persist via `setAnswer({...toSchedulerAnswerState(booking), type: "<record type>"})` and navigate. |
84
157
 
85
158
  ### Behavior + DOM
86
159
 
@@ -158,7 +158,27 @@ server links booking↔response with them; pass what you have.
158
158
  override `type` to the record's component type when persisting
159
159
  (`{...toSchedulerAnswerState(b), type: "CalendlyScreen"}`).
160
160
 
161
- The DOM widget over this client (`mountBookingWidget`) is in [`binding.md`](binding.md).
161
+ The DOM widget over this client (`mountBookingWidget`) and the zero-effort embedded path
162
+ (`mountScheduler`) are in [`binding.md`](binding.md).
163
+
164
+ - **`schedulerQuestionsOf(config): SchedulerQuestion[]`** (`core/client/runtimeConfig`) — the
165
+ record's scheduler questions with everything `mountScheduler` reads:
166
+
167
+ ```ts
168
+ type SchedulerQuestion = {
169
+ questionId;
170
+ stepId;
171
+ componentType: "CalendlyScreen" | "DynamicScheduler"; // also the persisted answer `type`
172
+ calendarType: string; // provider; the record omits it for the original default, Calendly
173
+ content: {
174
+ dataUrl?; eventTypeId?; savvyCalLinkId?; revenueHeroRouterId?;
175
+ dataUrlParams?: Record<string, { stepId; questionId; variable }>; // answer-state refs → embed params
176
+ linkNameToStep? / linkLastNameToStep? / linkEmailToStep?: { stepId; questionId }; // prefill refs
177
+ addSchedulerQueryParams?; skipFormIfPrefilled?;
178
+ calendars?; defaultCalendar?; // DynamicScheduler routing
179
+ };
180
+ };
181
+ ```
162
182
 
163
183
  ## Email validation (`core/client/emailValidation` + `validation/attachEmailValidation`)
164
184
 
@@ -30,7 +30,10 @@ creates one for you; construct directly for offline/custom stacks.
30
30
 
31
31
  - **`setAnswer(questionId, state)`** — merges (`{...prev, ...state}`) into the current step's
32
32
  answer for that question. Fires `started` (and the `form_started` event) exactly once, on the
33
- first deep divergence from the initial answers. Notifies subscribers.
33
+ first deep divergence from the initial answers. Notifies subscribers. After the finishing
34
+ write it amends the stored response (like `setExternalState`/`mergeMeta`) — a booking
35
+ confirmed on a thank-you-step scheduler still persists; before that, it never writes on its
36
+ own (writes ride navigation/`savePartial`).
34
37
  - **`next(): Promise<void>`** — resolves the next step. Not finished → emits `stepCompleted`,
35
38
  pushes the `form_step_submitted`+`form_step_viewed` batch (`stepCompletionTime` = seconds since
36
39
  engine creation), records the journey transition, writes a partial (if enabled) stamped with the
@@ -78,6 +81,7 @@ resumeToken, finished}`. The snapshot is cached until the next mutation, so it i
78
81
  | `disqualified` | `{stepId}` | Disqualified finish. |
79
82
  | `saved` | `{responseId, finished, resumeToken}` | After a CONFIRMED write: every create, every partial dispatch, the awaited terminal update. Never fires for a failed terminal write. |
80
83
  | `error` | `{scope: "submit", error}` | Failed create (typed `TransportError`) or failed terminal update. |
84
+ | `scheduler` | `SchedulerLifecycleEvent` | Scheduler lifecycle, emitted via `notifyScheduler` (the `mountScheduler` path — union in [`binding.md`](binding.md)). Emit-only. |
81
85
 
82
86
  ### Write serialization + delivery
83
87
 
@@ -2,7 +2,10 @@
2
2
 
3
3
  Exhaustive reference for the `core/events` domain. Map of everything: [`../api.md`](../api.md).
4
4
  (The engine's own lifecycle events — `viewed`/`started`/`stepChanged`/… — are in
5
- [`engine.md`](engine.md); this file covers the modules beneath them.)
5
+ [`engine.md`](engine.md); the `scheduler` channel's `SchedulerLifecycleEvent` union is in
6
+ [`binding.md`](binding.md) with `mountScheduler`. This file covers the modules beneath them.
7
+ Scheduler lifecycle events are client-side only — the push-event vocabulary below is a closed
8
+ server contract and gains no scheduler types.)
6
9
 
7
10
  ## Push events (`core/events/pushEvents`)
8
11
 
package/llms.txt CHANGED
@@ -45,9 +45,11 @@ Options beyond the four above: `preview: true` (no network writes at all — ide
45
45
  responses, events, journey all blocked; pass `previewToken` from the `create_form`
46
46
  or `get_sdk_binding_map` reply alongside it and a never-published form serves its
47
47
  DRAFT config, so you can preview before the first publish), `debug: true`
48
- (logs every captured answer + lifecycle event and warns on states the server
49
- would reject AND on inert configuration like a declared enrichment source with
50
- no enabled provider; development only), `journey: true` (page-view tracking),
48
+ (logs every captured answer + lifecycle event + navigation intent a
49
+ `next requested from step …` line per `next()`/`submit()`/… call, so a click
50
+ that never fired is distinguishable from a blocked advance — and warns on
51
+ states the server would reject AND on inert configuration like a declared
52
+ enrichment source with no enabled provider; development only), `journey: true` (page-view tracking),
51
53
  `urlDataStateId` (attribution pseudo-answer), `emailValidation: true`
52
54
  (automatic verdicts, see below), `resume`, `meta`, `source`.
53
55
 
@@ -95,7 +97,12 @@ Then:
95
97
  ### Choice questions bind as a GROUP
96
98
 
97
99
  Five types are group-built — `MultipleOptionsQuestion`, `MultipleChoiceQuestion`,
98
- `MMChoiceQuestion`, `GraphicOptions`, and `MultipleChoiceSingleAnswer`. Their
100
+ `MMChoiceQuestion`, `GraphicOptions`, and `MultipleChoiceSingleAnswer`. All five
101
+ bind when present in the record, but only the first three are creatable via
102
+ `create_form` (`list_component_types` is the authority) —
103
+ `MultipleChoiceQuestion` and `MultipleChoiceSingleAnswer` are legacy types you
104
+ will only meet on pre-existing forms; build new records with
105
+ `MultipleOptionsQuestion`. Their
99
106
  answers are structured (the record's whole option list / a selection object,
100
107
  and their shapes differ — only `MultipleOptionsQuestion` carries an option
101
108
  `index`; trust each question's `answerShape` from the binding map, never a
@@ -152,6 +159,11 @@ SPA route change), `validateEmail(email)`, `mergeMeta(partial)`,
152
159
  `disqualified`, `saved` (carries `responseId` + `resumeToken`), `error`. Every
153
160
  `on`/`subscribe` call returns an unsubscribe function.
154
161
 
162
+ `state().answers` is keyed by stepId, then questionId
163
+ (`Record<stepId, Record<questionId, state>>`) — reading an EARLIER step's
164
+ answer (booking prefill, a summary screen) indexes by that step's id:
165
+ `state().answers["step_about"]?.["q_name"]`, not by the current step.
166
+
155
167
  Delivery guarantees: the FINISHING write is awaited — when `submit()` resolves
156
168
  (and `saved` fires with `finished: true`) the server has the finished response;
157
169
  a failure emits `error` instead of lying. Mid-form partial saves are
@@ -219,7 +231,10 @@ Pass `emailValidation: true` to `init` for automatic mode: email answers are
219
231
  validated as they're captured (debounced; per-address, so nothing is billed
220
232
  twice) and the response carries the same `meta` verdict keys native forms
221
233
  write (`isEmailValid`, `emailValidation`, `emailValidationFailed`) for
222
- dashboards and workflows. Preview resolves synthetic valid verdicts with no
234
+ dashboards and workflows. Automatic mode ANNOTATES ONLY it never blocks
235
+ navigation or submission (an invalid address still submits, with
236
+ `isEmailValid: false` on the response); to actually gate, call
237
+ `validateEmail` in your own navigation as above. Preview resolves synthetic valid verdicts with no
223
238
  network. Offline callers wire it themselves: `createEmailValidator({
224
239
  transport, environmentId })` and optionally `attachEmailValidation({ engine,
225
240
  validator })`.
@@ -241,11 +256,41 @@ callers (caller-provided `steps`) wire it themselves: `attachEnrichment({
241
256
  engine, transport, formId, sources })` with sources from
242
257
  `enrichmentSourcesOf(config)`.
243
258
 
244
- ## Scheduler booking (Surface schedulers)
259
+ ## Scheduler booking
260
+
261
+ **Default path — one call, any provider.** `mountScheduler` reads the record's
262
+ scheduler question (provider, event URL/id, prefill links), renders a working
263
+ scheduler, tracks the lifecycle, and persists the booking into the response.
264
+ No `onComplete` wiring, no answer writing, no HTML binding:
265
+
266
+ ```js
267
+ import { mountScheduler } from "@parag.more.withsurface.com/forms-sdk";
268
+
269
+ const teardown = mountScheduler({
270
+ form, // the SurfaceForms.init handle
271
+ container: document.querySelector("#booking"),
272
+ // questionId: "q_booking", // only when the record has >1 scheduler question
273
+ onEvent: (e) => console.log(e.type), // optional; also on form.on("scheduler", …)
274
+ });
275
+ ```
245
276
 
246
- A record step with a Surface scheduler (`CalendlyScreen` with
247
- `calendarType: "Surface"`) renders through the SDK's own unstyled widget do
248
- NOT iframe the hosted booking page, and do NOT bind the question in HTML (the
277
+ Works for every provider native forms support — Surface's own scheduler
278
+ (rendered through the SDK widget, default-styled; `styled: false` keeps it
279
+ bare), Calendly, Cal.com, SavvyCal, Chili Piper, HubSpot, RevenueHero, Zoom,
280
+ Reclaim.ai, Clari — embedding each third-party widget and listening to its
281
+ booking events exactly like native forms do. Lifecycle events
282
+ (`viewed` / `date_selected` / `slot_selected` / `form_opened` /
283
+ `booking_confirmed` / `booking_cancelled`) emit on `form.on("scheduler")` and
284
+ the `onEvent` option; the confirmed booking is written to the response
285
+ automatically (`persist: false` opts out). Caveats: Clari exposes no booking
286
+ signal (embed-only — the visitor advances manually); Calendly/Zoom/Reclaim
287
+ create the response at mount when none exists yet (their embeds carry the
288
+ `responseId` for booking↔response linkage). Full options + provider matrix:
289
+ `docs/reference/binding.md`.
290
+
291
+ **Custom-UI path (advanced).** When you're building your own booking UI for a
292
+ Surface scheduler, mount the SDK's unstyled widget directly — do NOT iframe
293
+ the hosted booking page, and do NOT bind the question in HTML (the
249
294
  `onComplete` below writes the answer):
250
295
 
251
296
  ```js
@@ -269,7 +314,10 @@ The widget walks date → time → name/email form → booked. Labels are
269
314
  human-readable by default (`Intl` in the visitor's locale; every month/date/slot
270
315
  element also carries its raw value in `data-value` — `YYYY-MM`, `YYYY-MM-DD`,
271
316
  ISO instant); pass `formatters: { month?, date?, time? }` to override the copy.
272
- It injects no CSS — style it from your page. The rendered tree:
317
+ The widget itself injects no CSS — style it from your page. (`mountScheduler`'s
318
+ default styling is a stylesheet scoped under `.surface-scheduler` on ITS
319
+ container only; bare `mountBookingWidget` mounts are never restyled.) The
320
+ rendered tree:
273
321
 
274
322
  ```
275
323
  div.surface-booking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parag.more.withsurface.com/forms-sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Thin-client Surface Forms SDK: headless core over the public runtime endpoints (field-test build).",
5
5
  "license": "MIT",
6
6
  "sideEffects": false,
@@ -53,7 +53,13 @@ references alongside this file — read the one that matches the job:
53
53
  5. **Validate.** `validate_form_html` until clean. Scheduler/file questions
54
54
  report as notes (no binding needed — code writes their answers). If your
55
55
  navigation/submit is JS-driven, declare `data-surface-nav="js"` on any
56
- element instead of adding fake buttons.
56
+ element instead of adding fake buttons. For a JSX/React page the check is a
57
+ one-shot against transcribed markup — ALSO commit a drift test: extract
58
+ every `data-question-id` / `data-step-id` / choice `value` from the
59
+ component source (regex over the file is fine) and assert them against the
60
+ binding map's ids saved in your contract module. Binding drift is silent
61
+ data loss; only a committed test catches the refactor that renames a
62
+ question wrapper.
57
63
  6. **Preview before publishing.** `init({ preview: true, previewToken })`
58
64
  serves the DRAFT record with zero network writes; the scheduler returns a
59
65
  synthetic booking. Skip `simulate_response` — it evaluates record routing,
@@ -80,7 +86,9 @@ references alongside this file — read the one that matches the job:
80
86
  reveals the current one after async init.
81
87
  - **Teardown** — the finishing write is awaited (`submit()` resolving means the
82
88
  server has it), but partial saves are beacons: `await form.flush()` before a
83
- programmatic page close or your test harness records a partial.
89
+ programmatic page close or your test harness records a partial. In a React
90
+ effect cleanup call `destroy()` alone — a cleanup cannot await, and beacons
91
+ survive an SPA unmount; `flush()` is for closing the whole page/process.
84
92
  - **Email validation fails open** — `{ isValid: true, failed: true }` on vendor
85
93
  outage, and a fresh domain may resolve to a definitive verdict later. Check
86
94
  `failed` to soft-warn; don't write tests expecting stable verdicts for new
@@ -90,10 +98,15 @@ references alongside this file — read the one that matches the job:
90
98
  provider does nothing (`debug: true` warns).
91
99
  - **Custom values on the response** — `form.mergeMeta({ qualificationScore })`;
92
100
  reserved SDK keys are dropped. Don't invent external-state types.
93
- - **Scheduler** — mount the SDK's widget (never iframe the hosted page), write
94
- the answer in `onComplete` via `setAnswer({ ...toSchedulerAnswerState(b),
95
- type: "CalendlyScreen" })`. Labels are human-readable with raw values in
96
- `data-value`; style via the `surface-booking-*` classes (tree in llms.txt).
101
+ - **Scheduler** — default to ONE call: `mountScheduler({ form, container })`
102
+ renders the record's scheduler (any provider Surface widget default-styled,
103
+ or the third-party embed), tracks the lifecycle on `form.on("scheduler")`,
104
+ and persists the booking automatically. Never iframe the hosted booking
105
+ page. Build custom UI only when explicitly asked: `mountBookingWidget` +
106
+ `setAnswer({ ...toSchedulerAnswerState(b), type: "CalendlyScreen" })` in
107
+ `onComplete`. Traps: Clari emits no booking signal (embed-only);
108
+ Calendly/Zoom/Reclaim mounts create the response early (their embed URLs
109
+ carry the responseId); `styled: false` opts out of the injected scoped CSS.
97
110
  - **Business logic is invisible to Surface tooling.** Your qualification/
98
111
  branching thresholds live in page code no validator can see — test them in a
99
112
  real browser walk (both branches, plus a boundary case).
@@ -116,14 +116,38 @@ code-driven. Full event list for your own listeners: `viewed`, `started`, `stepC
116
116
  </div>
117
117
  ```
118
118
 
119
- ## Scheduler step (Surface scheduler)
119
+ ## Scheduler step
120
120
 
121
- A `CalendlyScreen` with `calendarType: "Surface"` renders through the SDK's own unstyled widget — never iframe the hosted booking page, and never bind the question in HTML (the `onComplete` writes the answer):
121
+ Default: one call. `mountScheduler` renders the record's scheduler question for ANY supported
122
+ provider (Surface's widget default-styled, or the record's third-party embed — Calendly,
123
+ Cal.com, SavvyCal, Chili Piper, HubSpot, RevenueHero, Zoom, Reclaim.ai, Clari), emits the
124
+ lifecycle on `form.on("scheduler")`, and persists the booking into the response by itself.
125
+ Never iframe the hosted booking page, and never bind the question in HTML:
122
126
 
123
127
  ```html
124
128
  <section data-step-id="step_book" hidden>
125
129
  <div id="booking"></div>
126
130
  </section>
131
+ <script>
132
+ const teardown = SurfaceFormsSDK.mountScheduler({
133
+ form,
134
+ container: document.querySelector("#booking"),
135
+ onEvent(e) {
136
+ if (e.type === "booking_confirmed") form.submit(); // or next()/goToStep, per your flow
137
+ },
138
+ });
139
+ </script>
140
+ ```
141
+
142
+ Note the booking itself needs no wiring — persistence is automatic (`persist: false` opts out);
143
+ `onEvent` here only drives navigation. `styled: false` skips the injected scoped stylesheet
144
+ (Surface provider). Clari is embed-only (no booking signal → drive navigation yourself).
145
+ In preview mode the Surface path books synthetically and nothing is written.
146
+
147
+ Custom booking UI (only when asked): mount the unstyled widget directly and write the answer
148
+ yourself —
149
+
150
+ ```html
127
151
  <script>
128
152
  const { createSchedulerClient, createTransport, mountBookingWidget, toSchedulerAnswerState } =
129
153
  SurfaceFormsSDK;
@@ -144,8 +168,7 @@ A `CalendlyScreen` with `calendarType: "Surface"` renders through the SDK's own
144
168
  The widget emits semantic markup under stable `surface-booking-*` classes (full DOM tree in
145
169
  `llms.txt`) and injects no CSS — style from the page. Labels are locale-formatted by default
146
170
  with the raw value on each element's `data-value`; pass `formatters: { month?, date?, time? }`
147
- to override the copy. In preview mode the client returns a synthetic booking, so the flow is
148
- testable offline.
171
+ to override the copy.
149
172
 
150
173
  ## React adapter (SPA)
151
174
 
@@ -209,7 +232,9 @@ function QualifySteps({ form }: { form: SurfaceForm }) {
209
232
  }
210
233
  ```
211
234
 
212
- `state` is `useSyncExternalStore`-backed: referentially stable between engine mutations, safe to render from directly. `submit()`/`disqualify()` move `state.currentStepId` to the record's matching ending step, so the same step-switch renders the confirmation. On SPA route changes call `form.identify()` to re-attribute the visitor; on programmatic teardown after a submit flow, `await form.flush()` first.
235
+ `state` is `useSyncExternalStore`-backed: referentially stable between engine mutations, safe to render from directly. `state.answers` is keyed by stepId then questionId — the example reads the current step (`state.answers[state.currentStepId]`), but an EARLIER step's answer (booking prefill, a summary screen) is read by that step's id: `state.answers["step_role"]?.["q_role"]`. `submit()`/`disqualify()` move `state.currentStepId` to the record's matching ending step, so the same step-switch renders the confirmation. On SPA route changes call `form.identify()` to re-attribute the visitor.
236
+
237
+ React teardown rule: `destroy()` alone in the effect cleanup is correct — a cleanup cannot await, partial saves are beacons that survive unmount, and the finishing write was already awaited by `submit()` itself before the cleanup can run. Reserve `await form.flush()` for programmatic teardown where the process itself may die with a write still queued (a test harness closing the browser), not for ordinary SPA unmounts.
213
238
 
214
239
  ## Tracking setup
215
240
 
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};