@flamingo-stack/openframe-frontend-core 0.0.672 → 0.0.673-2406.7238.1

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 (160) hide show
  1. package/dist/{chunk-PSL2WBEO.cjs → chunk-26NSIJMT.cjs} +33 -33
  2. package/dist/{chunk-PSL2WBEO.cjs.map → chunk-26NSIJMT.cjs.map} +1 -1
  3. package/dist/{chunk-UOQRNZIB.cjs → chunk-6QUKYN4K.cjs} +17 -17
  4. package/dist/{chunk-UOQRNZIB.cjs.map → chunk-6QUKYN4K.cjs.map} +1 -1
  5. package/dist/{chunk-AL4I5HHB.js → chunk-BF7GZ6BS.js} +4 -4
  6. package/dist/{chunk-CBCETEB4.cjs → chunk-DRURJCEY.cjs} +6 -6
  7. package/dist/{chunk-CBCETEB4.cjs.map → chunk-DRURJCEY.cjs.map} +1 -1
  8. package/dist/{chunk-GV7R2NTS.cjs → chunk-G262S3Q5.cjs} +23 -23
  9. package/dist/{chunk-GV7R2NTS.cjs.map → chunk-G262S3Q5.cjs.map} +1 -1
  10. package/dist/{chunk-HVEX4BSC.cjs → chunk-HJ532KQR.cjs} +60 -45
  11. package/dist/chunk-HJ532KQR.cjs.map +1 -0
  12. package/dist/{chunk-TQPKNG2E.js → chunk-HRM6242Y.js} +3 -3
  13. package/dist/{chunk-HOEVEB7N.cjs → chunk-IFQMIYFO.cjs} +50 -23
  14. package/dist/chunk-IFQMIYFO.cjs.map +1 -0
  15. package/dist/{chunk-Z6E45TJM.js → chunk-IG7M3XKX.js} +5 -5
  16. package/dist/{chunk-EKABOXNB.js → chunk-II6ABVLK.js} +2 -2
  17. package/dist/{chunk-ZRIVCLPR.js → chunk-JD5TKRCU.js} +1 -1
  18. package/dist/chunk-JD5TKRCU.js.map +1 -0
  19. package/dist/{chunk-SNWAN52K.cjs → chunk-K365MY6G.cjs} +3 -3
  20. package/dist/{chunk-SNWAN52K.cjs.map → chunk-K365MY6G.cjs.map} +1 -1
  21. package/dist/{chunk-NETIHUEY.cjs → chunk-KV7W5T6U.cjs} +10 -10
  22. package/dist/{chunk-NETIHUEY.cjs.map → chunk-KV7W5T6U.cjs.map} +1 -1
  23. package/dist/{chunk-H54F476P.cjs → chunk-KYF5EONP.cjs} +3 -3
  24. package/dist/{chunk-H54F476P.cjs.map → chunk-KYF5EONP.cjs.map} +1 -1
  25. package/dist/{chunk-D55IENZJ.cjs → chunk-LCMT2GZK.cjs} +1 -1
  26. package/dist/chunk-LCMT2GZK.cjs.map +1 -0
  27. package/dist/{chunk-TFSGYKWG.js → chunk-LMUZZUYC.js} +34 -7
  28. package/dist/chunk-LMUZZUYC.js.map +1 -0
  29. package/dist/{chunk-XLJNYTUW.cjs → chunk-N6TC6IPV.cjs} +56 -55
  30. package/dist/chunk-N6TC6IPV.cjs.map +1 -0
  31. package/dist/{chunk-IBGHJLNT.js → chunk-OGOMAK7B.js} +2 -2
  32. package/dist/{chunk-PWPYCJ33.js → chunk-P4GUIOLP.js} +2 -2
  33. package/dist/{chunk-7KM34VWL.js → chunk-RRU46J3N.js} +3 -3
  34. package/dist/{chunk-RD3I5PBB.cjs → chunk-THSIFQGS.cjs} +15 -15
  35. package/dist/{chunk-RD3I5PBB.cjs.map → chunk-THSIFQGS.cjs.map} +1 -1
  36. package/dist/{chunk-4AZWVS7F.js → chunk-TTHBFT7Q.js} +7 -6
  37. package/dist/{chunk-4AZWVS7F.js.map → chunk-TTHBFT7Q.js.map} +1 -1
  38. package/dist/{chunk-YNGAJGYB.cjs → chunk-TZP2OXHG.cjs} +346 -6
  39. package/dist/chunk-TZP2OXHG.cjs.map +1 -0
  40. package/dist/{chunk-VZRVQEMT.cjs → chunk-U2H3C4NT.cjs} +7 -7
  41. package/dist/{chunk-VZRVQEMT.cjs.map → chunk-U2H3C4NT.cjs.map} +1 -1
  42. package/dist/{chunk-KSBDARII.js → chunk-UFMXQKVT.js} +5 -5
  43. package/dist/{chunk-42OVTTOM.js → chunk-UMTXGEPU.js} +18 -3
  44. package/dist/{chunk-42OVTTOM.js.map → chunk-UMTXGEPU.js.map} +1 -1
  45. package/dist/{chunk-3PVOF3IB.js → chunk-UTHZ7DLN.js} +343 -3
  46. package/dist/{chunk-3PVOF3IB.js.map → chunk-UTHZ7DLN.js.map} +1 -1
  47. package/dist/{chunk-UUOBCUHW.cjs → chunk-UXT5EK27.cjs} +3 -3
  48. package/dist/{chunk-UUOBCUHW.cjs.map → chunk-UXT5EK27.cjs.map} +1 -1
  49. package/dist/{chunk-T3KXCOB2.js → chunk-VK5737GE.js} +2 -2
  50. package/dist/{chunk-O5PYXQ6H.js → chunk-VKLM5UKG.js} +3 -3
  51. package/dist/{chunk-6S2KLT7O.js → chunk-X2DR3XNN.js} +4 -4
  52. package/dist/{chunk-JREG4T6K.cjs → chunk-YCYOZAB5.cjs} +3 -3
  53. package/dist/{chunk-JREG4T6K.cjs.map → chunk-YCYOZAB5.cjs.map} +1 -1
  54. package/dist/{chunk-XDTU23P4.js → chunk-YWQVL5VJ.js} +2 -2
  55. package/dist/{chunk-BODMICIH.cjs → chunk-Z5K4IQZU.cjs} +40 -40
  56. package/dist/{chunk-BODMICIH.cjs.map → chunk-Z5K4IQZU.cjs.map} +1 -1
  57. package/dist/components/case-studies/index.cjs +15 -14
  58. package/dist/components/case-studies/index.cjs.map +1 -1
  59. package/dist/components/case-studies/index.js +6 -5
  60. package/dist/components/case-studies/index.js.map +1 -1
  61. package/dist/components/case-studies/share-experience-section.d.ts.map +1 -1
  62. package/dist/components/contact/contact-form.d.ts +6 -1
  63. package/dist/components/contact/contact-form.d.ts.map +1 -1
  64. package/dist/components/contact/index.cjs +5 -5
  65. package/dist/components/contact/index.js +4 -4
  66. package/dist/components/docs/index.cjs +8 -8
  67. package/dist/components/docs/index.js +7 -7
  68. package/dist/components/embeds/index.cjs +5 -5
  69. package/dist/components/embeds/index.js +4 -4
  70. package/dist/components/faq/index.cjs +5 -5
  71. package/dist/components/faq/index.js +4 -4
  72. package/dist/components/features/index.cjs +4 -4
  73. package/dist/components/features/index.js +3 -3
  74. package/dist/components/features/waitlist-form.d.ts +7 -2
  75. package/dist/components/features/waitlist-form.d.ts.map +1 -1
  76. package/dist/components/help-center-pages/index.cjs +53 -52
  77. package/dist/components/help-center-pages/index.cjs.map +1 -1
  78. package/dist/components/help-center-pages/index.js +16 -15
  79. package/dist/components/help-center-pages/index.js.map +1 -1
  80. package/dist/components/help-center-pages/trust-center-sections.d.ts.map +1 -1
  81. package/dist/components/index.cjs +35 -35
  82. package/dist/components/index.js +14 -14
  83. package/dist/components/meeting-scheduler/booking-form.d.ts +4 -1
  84. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  85. package/dist/components/meeting-scheduler/index.cjs +72 -37
  86. package/dist/components/meeting-scheduler/index.cjs.map +1 -1
  87. package/dist/components/meeting-scheduler/index.d.ts +5 -1
  88. package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
  89. package/dist/components/meeting-scheduler/index.js +45 -10
  90. package/dist/components/meeting-scheduler/index.js.map +1 -1
  91. package/dist/components/navigation/index.cjs +7 -7
  92. package/dist/components/navigation/index.js +6 -6
  93. package/dist/components/onboarding-guides/index.cjs +7 -7
  94. package/dist/components/onboarding-guides/index.js +6 -6
  95. package/dist/components/tickets/help-center-create-form.d.ts.map +1 -1
  96. package/dist/components/tickets/index.cjs +8 -8
  97. package/dist/components/tickets/index.js +7 -7
  98. package/dist/components/ui/index.cjs +4 -4
  99. package/dist/components/ui/index.js +3 -3
  100. package/dist/contexts/endpoints-runtime-context.d.ts +5 -0
  101. package/dist/contexts/endpoints-runtime-context.d.ts.map +1 -1
  102. package/dist/contexts/index.cjs +2 -2
  103. package/dist/contexts/index.js +1 -1
  104. package/dist/hooks/index.cjs +5 -3
  105. package/dist/hooks/index.cjs.map +1 -1
  106. package/dist/hooks/index.d.ts +1 -0
  107. package/dist/hooks/index.d.ts.map +1 -1
  108. package/dist/hooks/index.js +4 -2
  109. package/dist/hooks/use-form-rescue.d.ts +37 -0
  110. package/dist/hooks/use-form-rescue.d.ts.map +1 -0
  111. package/dist/index.cjs +52 -8
  112. package/dist/index.cjs.map +1 -1
  113. package/dist/index.js +51 -7
  114. package/dist/index.js.map +1 -1
  115. package/dist/utils/form-rescue.cjs +123 -0
  116. package/dist/utils/form-rescue.cjs.map +1 -0
  117. package/dist/utils/form-rescue.d.ts +105 -0
  118. package/dist/utils/form-rescue.d.ts.map +1 -0
  119. package/dist/utils/form-rescue.js +101 -0
  120. package/dist/utils/form-rescue.js.map +1 -0
  121. package/dist/utils/index.cjs +119 -0
  122. package/dist/utils/index.cjs.map +1 -1
  123. package/dist/utils/index.d.ts +1 -0
  124. package/dist/utils/index.d.ts.map +1 -1
  125. package/dist/utils/index.js +99 -1
  126. package/dist/utils/index.js.map +1 -1
  127. package/package.json +7 -1
  128. package/src/components/case-studies/share-experience-section.tsx +1 -0
  129. package/src/components/contact/contact-form.tsx +38 -2
  130. package/src/components/features/waitlist-form.tsx +27 -2
  131. package/src/components/help-center-pages/trust-center-sections.tsx +1 -0
  132. package/src/components/meeting-scheduler/booking-form.tsx +14 -0
  133. package/src/components/meeting-scheduler/index.tsx +38 -3
  134. package/src/components/tickets/help-center-create-form.tsx +2 -0
  135. package/src/contexts/endpoints-runtime-context.tsx +5 -0
  136. package/src/hooks/__tests__/use-form-rescue.test.tsx +189 -0
  137. package/src/hooks/index.ts +3 -0
  138. package/src/hooks/use-form-rescue.ts +315 -0
  139. package/src/utils/__tests__/form-rescue.test.ts +106 -0
  140. package/src/utils/form-rescue.ts +198 -0
  141. package/src/utils/index.ts +5 -0
  142. package/dist/chunk-D55IENZJ.cjs.map +0 -1
  143. package/dist/chunk-HOEVEB7N.cjs.map +0 -1
  144. package/dist/chunk-HVEX4BSC.cjs.map +0 -1
  145. package/dist/chunk-TFSGYKWG.js.map +0 -1
  146. package/dist/chunk-XLJNYTUW.cjs.map +0 -1
  147. package/dist/chunk-YNGAJGYB.cjs.map +0 -1
  148. package/dist/chunk-ZRIVCLPR.js.map +0 -1
  149. /package/dist/{chunk-AL4I5HHB.js.map → chunk-BF7GZ6BS.js.map} +0 -0
  150. /package/dist/{chunk-TQPKNG2E.js.map → chunk-HRM6242Y.js.map} +0 -0
  151. /package/dist/{chunk-Z6E45TJM.js.map → chunk-IG7M3XKX.js.map} +0 -0
  152. /package/dist/{chunk-EKABOXNB.js.map → chunk-II6ABVLK.js.map} +0 -0
  153. /package/dist/{chunk-IBGHJLNT.js.map → chunk-OGOMAK7B.js.map} +0 -0
  154. /package/dist/{chunk-PWPYCJ33.js.map → chunk-P4GUIOLP.js.map} +0 -0
  155. /package/dist/{chunk-7KM34VWL.js.map → chunk-RRU46J3N.js.map} +0 -0
  156. /package/dist/{chunk-KSBDARII.js.map → chunk-UFMXQKVT.js.map} +0 -0
  157. /package/dist/{chunk-T3KXCOB2.js.map → chunk-VK5737GE.js.map} +0 -0
  158. /package/dist/{chunk-O5PYXQ6H.js.map → chunk-VKLM5UKG.js.map} +0 -0
  159. /package/dist/{chunk-6S2KLT7O.js.map → chunk-X2DR3XNN.js.map} +0 -0
  160. /package/dist/{chunk-XDTU23P4.js.map → chunk-YWQVL5VJ.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.672",
3
+ "version": "0.0.673-2406.7238.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -229,6 +229,12 @@
229
229
  "require": "./dist/utils/humanity-signals.cjs",
230
230
  "default": "./dist/utils/humanity-signals.js"
231
231
  },
232
+ "./utils/form-rescue": {
233
+ "types": "./dist/utils/form-rescue.d.ts",
234
+ "import": "./dist/utils/form-rescue.js",
235
+ "require": "./dist/utils/form-rescue.cjs",
236
+ "default": "./dist/utils/form-rescue.js"
237
+ },
232
238
  "./utils/platform-identity": {
233
239
  "types": "./dist/utils/platform-identity.d.ts",
234
240
  "import": "./dist/utils/platform-identity.js",
@@ -93,6 +93,7 @@ const DEFAULT_CONTACT_FORM_PROPS = {
93
93
  buttonVariant: 'outline' as ContactFormProps['buttonVariant'],
94
94
  buttonClassName: 'w-full',
95
95
  successToastMessage: "Thank you! We'll reach out to schedule your case study.",
96
+ rescueFormId: 'case_study_pitch',
96
97
  } satisfies Partial<ContactFormProps>;
97
98
 
98
99
  export function ShareExperienceSection({
@@ -22,9 +22,10 @@
22
22
  */
23
23
 
24
24
  import { zodResolver } from '@hookform/resolvers/zod';
25
- import { useState, type ReactNode } from 'react';
25
+ import { useEffect, useMemo, useState, type ReactNode } from 'react';
26
26
  import { useForm, Controller } from 'react-hook-form';
27
27
  import { useContactSubmission } from '../../hooks/use-contact-submission';
28
+ import { useFormRescue } from '../../hooks/use-form-rescue';
28
29
  import { useHumanitySignals } from '../../hooks/use-humanity-signals';
29
30
  import {
30
31
  ContactSchema,
@@ -33,6 +34,7 @@ import {
33
34
  referralSourceOptions,
34
35
  defaultHelpCategoryOptions,
35
36
  } from '../../schemas/contact-schema';
37
+ import type { FormRescueFormId } from '../../utils/form-rescue';
36
38
  import { HUBSPOT_DO_NOT_COLLECT_FORM_PROPS } from '../../utils/hubspot-collected-forms';
37
39
  import { ChatAttachmentAddButton, ChatAttachmentChipStrip } from '../chat/chat-attachment-bar';
38
40
  import { useChatAttachments } from '../chat/hooks/use-chat-attachments';
@@ -130,6 +132,10 @@ export interface ContactFormProps {
130
132
  submitSuccessLabel?: string;
131
133
  successRedirectUrl?: string;
132
134
  successToastMessage?: string;
135
+ /** Which public form this is, for form rescue (a half-filled form is saved so
136
+ * the team can follow up). OPT-IN: omitted or `null` saves nothing, so a host
137
+ * that has not chosen rescue never starts storing what visitors type. */
138
+ rescueFormId?: FormRescueFormId | null;
133
139
  }
134
140
 
135
141
  export function ContactForm({
@@ -155,6 +161,7 @@ export function ContactForm({
155
161
  submitSuccessLabel = 'Message Sent!',
156
162
  successRedirectUrl = '/blog#community',
157
163
  successToastMessage = 'Redirecting you to join our community...',
164
+ rescueFormId = null,
158
165
  }: ContactFormProps = {}) {
159
166
  // Attachments staging — same hook the chat composer + ticket
160
167
  // detail-drawer composer use. Files upload to Supabase as soon as
@@ -191,6 +198,9 @@ export function ContactForm({
191
198
  control,
192
199
  formState: { errors },
193
200
  reset,
201
+ watch,
202
+ getValues,
203
+ setValue,
194
204
  } = useForm<ContactFormData>({
195
205
  resolver: zodResolver(ContactSchema),
196
206
  defaultValues: {
@@ -202,11 +212,36 @@ export function ContactForm({
202
212
  },
203
213
  });
204
214
 
215
+ // Form rescue: only the fields the visitor can see are tracked, so a hidden
216
+ // prefilled value (a signed-in email, a fixed category) is never sent.
217
+ const rescueFieldNames = useMemo(
218
+ () =>
219
+ (['name', 'email', 'companySize', 'referralSource', 'helpCategory', 'message'] as const).filter(
220
+ field => !hideFields.includes(field),
221
+ ),
222
+ [hideFields],
223
+ );
224
+ const rescue = useFormRescue({
225
+ formId: rescueFormId,
226
+ fieldNames: rescueFieldNames,
227
+ getSignals,
228
+ onRestore: values => {
229
+ for (const field of rescueFieldNames) {
230
+ const value = values[field];
231
+ if (value && !getValues(field)) setValue(field, value);
232
+ }
233
+ },
234
+ });
235
+ useEffect(() => {
236
+ const subscription = watch((values, { name }) => rescue.track(values, name));
237
+ return () => subscription.unsubscribe();
238
+ }, [watch, rescue]);
239
+
205
240
  const handleFormSubmit = async (data: ContactFormData) => {
206
241
  if (isSubmitting) return;
207
242
  if (attachmentsEnabled && attachments.hasInflightUploads) return;
208
243
  try {
209
- const payload = { ...data, ...(rdtCid && { rdt_cid: rdtCid }), ...getSignals() };
244
+ const payload = { ...data, ...(rdtCid && { rdt_cid: rdtCid }), ...getSignals(), ...rescue.submitFields() };
210
245
  const readyAttachments = attachmentsEnabled ? attachments.readyAttachments : [];
211
246
  if (onCustomSubmit) {
212
247
  setCustomSubmitting(true);
@@ -218,6 +253,7 @@ export function ContactForm({
218
253
  } else {
219
254
  await builtInSubmission.submit(payload);
220
255
  }
256
+ rescue.complete();
221
257
  onSubmitSuccess?.();
222
258
  reset();
223
259
  resetSignals();
@@ -5,10 +5,12 @@ import type { CountryCode } from 'libphonenumber-js';
5
5
  import { useEffect, useState } from 'react';
6
6
  import type { KeyboardEvent } from 'react';
7
7
  import { useIsHydrated } from '../../hooks/ui/use-is-hydrated';
8
+ import { useFormRescue } from '../../hooks/use-form-rescue';
8
9
  import { useHumanitySignals } from '../../hooks/use-humanity-signals';
9
10
  import { useToast } from '../../hooks/use-toast';
10
11
  import { cn } from '../../utils/cn';
11
12
  import { formatPhoneE164 } from '../../utils/country-phone-utils';
13
+ import type { FormRescueFormId } from '../../utils/form-rescue';
12
14
  import { hasGenericEmailDomain } from '../../utils/generic-domain-utils';
13
15
  import type { HumanitySignals } from '../../utils/humanity-signals';
14
16
  import { OpenFrameLogo } from '../icons';
@@ -30,7 +32,14 @@ export interface WaitlistFormProps {
30
32
  * POST body. Must throw on failure (toast is handled by the form). `signals`
31
33
  * is optional for backward compatibility with older callers.
32
34
  */
33
- onRegister: (email: string, phone?: string, signals?: HumanitySignals) => Promise<void>;
35
+ /** `rescue` carries the form-rescue keys (`form_attempt_id`, …) to merge into the
36
+ * POST body, so the host closes the half-filled draft this submit completes. */
37
+ onRegister: (
38
+ email: string,
39
+ phone?: string,
40
+ signals?: HumanitySignals,
41
+ rescue?: Record<string, string>,
42
+ ) => Promise<void>;
34
43
  /** Whether a registration request is currently in flight */
35
44
  isSubmitting?: boolean;
36
45
  /** Whether registration completed successfully */
@@ -57,6 +66,8 @@ export interface WaitlistFormProps {
57
66
  privacyPolicyUrl?: string;
58
67
  /** SMS consent text shown below the checkbox label */
59
68
  consentText?: string;
69
+ /** Form rescue (save a half-filled form for follow-up). OPT-IN: omitted or `null` saves nothing. */
70
+ rescueFormId?: FormRescueFormId | null;
60
71
  }
61
72
 
62
73
  /**
@@ -90,6 +101,7 @@ export function WaitlistForm({
90
101
  invalidPhoneHint = 'Invalid phone number format.',
91
102
  termsOfServiceUrl,
92
103
  privacyPolicyUrl,
104
+ rescueFormId = null,
93
105
  consentText = 'I agree to receive recurring automated text messages at the phone number provided. Msg & data rates may apply. Msg frequency varies. Reply HELP for help and STOP to cancel.',
94
106
  }: WaitlistFormProps) {
95
107
  const [email, setEmail] = useState(defaultEmail);
@@ -101,6 +113,18 @@ export function WaitlistForm({
101
113
  const isClient = useIsHydrated();
102
114
  const [isPhoneInvalid, setIsPhoneInvalid] = useState(false);
103
115
  const [showConsentError, setShowConsentError] = useState(false);
116
+ const rescue = useFormRescue({
117
+ formId: rescueFormId,
118
+ // The phone is reported as filled only; its value never leaves the form early.
119
+ fieldNames: ['email', 'phone'],
120
+ getSignals,
121
+ onRestore: values => {
122
+ if (values.email) setEmail(current => current || values.email);
123
+ },
124
+ });
125
+ useEffect(() => {
126
+ rescue.track({ email, phone }, null);
127
+ }, [email, phone, rescue]);
104
128
 
105
129
  const isMailDomainGeneric = hasGenericEmailDomain(email);
106
130
 
@@ -160,7 +184,8 @@ export function WaitlistForm({
160
184
  const finalPhone = phone ? formatPhoneE164(phone, countryCode) : undefined;
161
185
 
162
186
  try {
163
- await onRegister(email, finalPhone, getSignals());
187
+ await onRegister(email, finalPhone, getSignals(), rescue.submitFields());
188
+ rescue.complete();
164
189
  resetSignals();
165
190
  } catch {
166
191
  // caller's onRegister should handle its own error toasts if needed
@@ -443,6 +443,7 @@ export function DocumentRequestModal({
443
443
  with access, usually within one business day.
444
444
  </p>
445
445
  <ContactForm
446
+ rescueFormId="trust_center_request"
446
447
  title=""
447
448
  noBorder
448
449
  noPadding
@@ -370,6 +370,9 @@ export interface BookingFormProps {
370
370
  /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */
371
371
  honeypotInputProps: { ref: Ref<HTMLInputElement>; name: string };
372
372
  getSignals: () => Record<string, string | number>;
373
+ /** Called on every edit with the answers flattened (declared questions beside
374
+ * the built-ins) and the edited field's name — the parent's form rescue. */
375
+ onValuesChange?: (values: Record<string, unknown>, changedField: string | null) => void;
373
376
  }
374
377
 
375
378
  /**
@@ -402,6 +405,7 @@ export function BookingForm({
402
405
  onSubmit,
403
406
  honeypotInputProps,
404
407
  getSignals,
408
+ onValuesChange,
405
409
  }: BookingFormProps) {
406
410
  const { formFields, legalConsent } = availability;
407
411
  // Every declared question resolved to a control (`resolveFormFieldControl`) —
@@ -431,6 +435,7 @@ export function BookingForm({
431
435
  handleSubmit,
432
436
  setValue,
433
437
  getValues,
438
+ watch,
434
439
  formState: { errors },
435
440
  } = useForm<BookingFormValues>({
436
441
  resolver: zodResolver(schema),
@@ -479,6 +484,15 @@ export function BookingForm({
479
484
  );
480
485
  }, [consentDefaults, priorConsents, getValues, setValue]);
481
486
 
487
+ useEffect(() => {
488
+ if (!onValuesChange) return undefined;
489
+ const subscription = watch((values, { name }) => {
490
+ const answers = (values.formFields ?? {}) as Record<string, unknown>;
491
+ onValuesChange({ ...values, ...answers }, name ? name.replace(/^formFields\./, '') : null);
492
+ });
493
+ return () => subscription.unsubscribe();
494
+ }, [watch, onValuesChange]);
495
+
482
496
  const submitValid = handleSubmit(async data => {
483
497
  if (consentMissing) return; // the error is already on screen — see `submit`
484
498
  if (deferSlot) {
@@ -42,17 +42,21 @@
42
42
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
43
43
  import type { ComponentType, ReactNode } from 'react';
44
44
  import { useIsHydrated } from '../../hooks/ui/use-is-hydrated';
45
+ import { useFormRescue } from '../../hooks/use-form-rescue';
45
46
  import { useHumanitySignals } from '../../hooks/use-humanity-signals';
46
47
  import { BOOKING_IN_FLIGHT_MESSAGE, useMeetingBooking } from '../../hooks/use-meeting-booking';
47
48
  import { useToast } from '../../hooks/use-toast';
48
49
  import {
49
50
  blocksNativeBooking,
51
+ BUILT_IN_BOOKING_FIELDS,
52
+ normalizeFormFields,
50
53
  type BookingConfirmation,
51
54
  type MeetingAvailability,
52
55
  type MeetingBookingErrorCode,
53
56
  type MeetingHost,
54
57
  } from '../../schemas/meeting-booking-schema';
55
58
  import { cn } from '../../utils/cn';
59
+ import type { FormRescueFormId } from '../../utils/form-rescue';
56
60
  import { formatDurationCompact, formatDateWithTimezone } from '../../utils/format';
57
61
  import { Alert, AlertDescription, Button } from '../ui';
58
62
  import { BookingForm, BookingFormSkeleton, DEFAULT_SUBMIT_LABEL, type BookingFormProps } from './booking-form';
@@ -150,6 +154,9 @@ export interface HubSpotMeetingSchedulerProps {
150
154
  * can pass it across the RSC boundary where a component cannot.
151
155
  */
152
156
  detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent'>;
157
+ /** Form rescue (save the half-filled booking form for follow-up). OPT-IN:
158
+ * omitted or `null` saves nothing. */
159
+ rescueFormId?: FormRescueFormId | null;
153
160
  }
154
161
 
155
162
  type Step = 'slot' | 'details' | 'confirmed';
@@ -403,6 +410,7 @@ export function HubSpotMeetingScheduler({
403
410
  flow = DEFAULT_SCHEDULER_FLOW,
404
411
  detailsForm: DetailsForm = BookingForm,
405
412
  detailsFormProps,
413
+ rescueFormId = null,
406
414
  }: HubSpotMeetingSchedulerProps) {
407
415
  const {
408
416
  availability,
@@ -492,6 +500,30 @@ export function HubSpotMeetingScheduler({
492
500
  const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals();
493
501
  const { toast } = useToast();
494
502
 
503
+ // Form rescue lives HERE, not in the form: in details-first the form unmounts
504
+ // before the POST, and the attempt must survive it. The form reports edits
505
+ // (`onValuesChange`); a restored draft remounts it with the answers seeded.
506
+ const rescueFieldNames = useMemo(
507
+ () => [
508
+ ...BUILT_IN_BOOKING_FIELDS.map(field => field.name),
509
+ ...normalizeFormFields(availability?.formFields ?? []).map(field => field.name),
510
+ ],
511
+ [availability?.formFields],
512
+ );
513
+ const [restored, setRestored] = useState<{ version: number; values: Record<string, unknown> } | null>(null);
514
+ const rescue = useFormRescue({
515
+ formId: rescueFormId,
516
+ fieldNames: rescueFieldNames,
517
+ getSignals,
518
+ onRestore: values => {
519
+ const { email, firstName, lastName, ...answers } = values;
520
+ setRestored(prev => ({
521
+ version: (prev?.version ?? 0) + 1,
522
+ values: { email: email ?? '', firstName: firstName ?? '', lastName: lastName ?? '', formFields: answers },
523
+ }));
524
+ },
525
+ });
526
+
495
527
  // Reset the machine when the host switches links. Adjusted while rendering —
496
528
  // React's documented pattern for a prop-driven reset — so the swapped-in link
497
529
  // never paints a frame of the previous link's chosen day, slot or
@@ -634,7 +666,7 @@ export function HubSpotMeetingScheduler({
634
666
  // visible SLOT_TAKEN alert on its way to being ignored.
635
667
  const priorError = bookingError;
636
668
  setBookingError(null);
637
- const result = await book(payload);
669
+ const result = await book({ ...payload, ...rescue.submitFields() });
638
670
 
639
671
  // Not an error: the hook's in-flight guard. No toast, no step change.
640
672
  if (!result.ok && result.message === BOOKING_IN_FLIGHT_MESSAGE) {
@@ -642,6 +674,7 @@ export function HubSpotMeetingScheduler({
642
674
  return;
643
675
  }
644
676
  if (result.ok && result.confirmation) {
677
+ rescue.complete();
645
678
  setConfirmation(result.confirmation);
646
679
  setStep('confirmed');
647
680
  onBooked?.(result.confirmation);
@@ -681,7 +714,7 @@ export function HubSpotMeetingScheduler({
681
714
  });
682
715
  }
683
716
  },
684
- [book, bookingError, detailsFirst, onBooked, refetchAvailability, resetSignals, toast],
717
+ [book, bookingError, detailsFirst, onBooked, refetchAvailability, rescue, resetSignals, toast],
685
718
  );
686
719
 
687
720
  const escapeHatch = fallbackUrl ? (
@@ -853,6 +886,7 @@ export function HubSpotMeetingScheduler({
853
886
  </p>
854
887
  )}
855
888
  <DetailsForm
889
+ key={restored?.version ?? 0}
856
890
  {...detailsFormProps}
857
891
  availability={availability}
858
892
  meetingId={meetingId}
@@ -862,11 +896,12 @@ export function HubSpotMeetingScheduler({
862
896
  deferSlot={detailsFirst}
863
897
  submitLabel={preset.submitLabel}
864
898
  footerNote={preset.footerNote}
865
- initialValues={detailsFirst ? stash?.payload : undefined}
899
+ initialValues={(detailsFirst ? stash?.payload : undefined) ?? restored?.values}
866
900
  isSubmitting={isSubmitting}
867
901
  onSubmit={detailsFirst ? stashDetails : handleSubmit}
868
902
  honeypotInputProps={honeypotInputProps}
869
903
  getSignals={getSignals}
904
+ onValuesChange={rescue.track}
870
905
  />
871
906
  </div>
872
907
  ) : (
@@ -205,6 +205,8 @@ export function HelpCenterCreateForm({
205
205
 
206
206
  return (
207
207
  <ContactForm
208
+ // A signed-in support ticket, not a lead: nothing to rescue.
209
+ rescueFormId={null}
208
210
  title="Open a new ticket"
209
211
  footerText="The support team typically responds within one business day."
210
212
  hideFields={['name', 'email', 'companySize', 'referralSource', 'helpCategory']}
@@ -34,6 +34,11 @@ export interface EndpointsRuntime {
34
34
  };
35
35
  /** POST contact-form submission. */
36
36
  contactUrl: string;
37
+ /** Base of the host's form-rescue draft endpoints: `POST <base>` saves a
38
+ * half-filled public form (upsert by `attempt_id`), `GET <base>/resume/<token>` reads one
39
+ * back for a resume link (`useFormRescue`). OPTIONAL: a host without them
40
+ * omits it and forms keep only this device's local draft. */
41
+ formDraftsUrl?: string;
37
42
  /** GET base of the host's private-storage view proxy
38
43
  * (`<base>/<bucket>/<object>`). `ClaudeEmbed` derives artifact mirror
39
44
  * urls under it (`<base>/design-briefs/<uuid>.html`). OPTIONAL: a host
@@ -0,0 +1,189 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import type { ReactNode } from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
4
+
5
+ import { EndpointsRuntimeContext, type EndpointsRuntime } from '../../contexts/endpoints-runtime-context';
6
+ import { FORM_RESCUE_DEBOUNCE_MS } from '../../utils/form-rescue';
7
+ import { useFormRescue } from '../use-form-rescue';
8
+
9
+ /**
10
+ * The hook's contract is a network and privacy contract, so it is asserted on
11
+ * the fetch spy: one debounced save per burst of typing, only allowlisted
12
+ * values in the body, no save before anything is filled, no analytics event
13
+ * carrying a value, and a submit that closes the attempt it saved.
14
+ */
15
+
16
+ const runtime: EndpointsRuntime = {
17
+ announcementsUrl: '/api/announcements',
18
+ accessCode: { validateUrl: '/v', consumeUrl: '/c' },
19
+ contactUrl: '/api/contact',
20
+ formDraftsUrl: '/api/contact/drafts',
21
+ };
22
+
23
+ function wrapper({ children }: { children: ReactNode }) {
24
+ return <EndpointsRuntimeContext.Provider value={runtime}>{children}</EndpointsRuntimeContext.Provider>;
25
+ }
26
+
27
+ const FIELDS = ['name', 'email', 'message'] as const;
28
+
29
+ let fetchSpy: Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>;
30
+ let captures: Array<{ event: string; props: Record<string, unknown> }>;
31
+
32
+ beforeEach(() => {
33
+ vi.useFakeTimers();
34
+ window.localStorage.clear();
35
+ fetchSpy = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
36
+ Promise.resolve(new Response(JSON.stringify({ status: 'started' }), { status: 202 })),
37
+ );
38
+ vi.stubGlobal('fetch', fetchSpy);
39
+ captures = [];
40
+ (window as unknown as { posthog: unknown }).posthog = {
41
+ capture: (event: string, props: Record<string, unknown>) => captures.push({ event, props }),
42
+ };
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.useRealTimers();
47
+ vi.unstubAllGlobals();
48
+ delete (window as unknown as { posthog?: unknown }).posthog;
49
+ });
50
+
51
+ interface SentBody {
52
+ attempt_id: string;
53
+ [key: string]: unknown;
54
+ }
55
+
56
+ function sentBodies(): Array<{ url: string; body: SentBody }> {
57
+ return fetchSpy.mock.calls
58
+ .filter(([, init]) => init?.method === 'POST')
59
+ .map(([url, init]) => ({ url: String(url), body: JSON.parse(String(init?.body)) as SentBody }));
60
+ }
61
+
62
+ describe('useFormRescue', () => {
63
+ it('debounces one save per burst with allowlisted values only', () => {
64
+ const { result } = renderHook(() => useFormRescue({ formId: 'contact', fieldNames: FIELDS }), { wrapper });
65
+
66
+ act(() => {
67
+ result.current.track({ name: 'Al', email: '', message: '' }, 'name');
68
+ result.current.track({ name: 'Alex', email: 'alex@northwind-it.com', message: 'secret text' }, 'message');
69
+ });
70
+ expect(sentBodies()).toHaveLength(0);
71
+
72
+ act(() => {
73
+ vi.advanceTimersByTime(FORM_RESCUE_DEBOUNCE_MS);
74
+ });
75
+ const sent = sentBodies();
76
+ expect(sent).toHaveLength(1);
77
+ expect(sent[0].url).toBe('/api/contact/drafts');
78
+ expect(sent[0].body.attempt_id).toMatch(/^[0-9a-f-]{36}$/);
79
+ expect(sent[0].body).toMatchObject({
80
+ form_id: 'contact',
81
+ values: { name: 'Alex', email: 'alex@northwind-it.com' },
82
+ fields_filled: ['name', 'email', 'message'],
83
+ last_field: 'message',
84
+ completion_pct: 100,
85
+ });
86
+ expect(JSON.stringify(sent[0].body)).not.toContain('secret text');
87
+ });
88
+
89
+ it('sends nothing until a field is filled', () => {
90
+ const { result } = renderHook(() => useFormRescue({ formId: 'contact', fieldNames: FIELDS }), { wrapper });
91
+ act(() => {
92
+ result.current.track({ name: '', email: '', message: '' }, 'name');
93
+ vi.advanceTimersByTime(FORM_RESCUE_DEBOUNCE_MS * 2);
94
+ });
95
+ expect(sentBodies()).toHaveLength(0);
96
+ });
97
+
98
+ it('never puts a field value into an analytics event', () => {
99
+ const { result } = renderHook(() => useFormRescue({ formId: 'contact', fieldNames: FIELDS }), { wrapper });
100
+ act(() => {
101
+ result.current.track({ name: 'Alex', email: 'alex@northwind-it.com', message: '' }, 'email');
102
+ vi.advanceTimersByTime(FORM_RESCUE_DEBOUNCE_MS);
103
+ });
104
+ expect(captures.map(c => c.event)).toEqual(['form_started', 'form_lead_captured', 'form_progress']);
105
+ const serialized = JSON.stringify(captures);
106
+ expect(serialized).not.toContain('alex@northwind-it.com');
107
+ expect(serialized).not.toContain('Alex"');
108
+ });
109
+
110
+ it('carries the saved attempt into the submit and starts a fresh one after', () => {
111
+ const { result } = renderHook(() => useFormRescue({ formId: 'contact', fieldNames: FIELDS }), { wrapper });
112
+ act(() => {
113
+ result.current.track({ name: 'Alex', email: '', message: '' }, 'name');
114
+ vi.advanceTimersByTime(FORM_RESCUE_DEBOUNCE_MS);
115
+ });
116
+ const savedId = sentBodies()[0].body.attempt_id;
117
+ expect(result.current.submitFields()).toEqual({ form_attempt_id: savedId });
118
+
119
+ act(() => result.current.complete());
120
+ expect(window.localStorage.getItem('form-rescue:v1:contact')).toBeNull();
121
+ expect(result.current.submitFields().form_attempt_id).not.toBe(savedId);
122
+ });
123
+
124
+ it('restores this device’s draft and keeps its attempt', () => {
125
+ window.localStorage.setItem(
126
+ 'form-rescue:v1:contact',
127
+ JSON.stringify({
128
+ attemptId: '6f1c2a7e-3b9d-4e21-9a4f-0c8d5e7b1a22',
129
+ values: { email: 'alex@northwind-it.com', password: 'never' },
130
+ savedAt: Date.now(),
131
+ }),
132
+ );
133
+ const onRestore = vi.fn();
134
+ const { result } = renderHook(() => useFormRescue({ formId: 'contact', fieldNames: FIELDS, onRestore }), {
135
+ wrapper,
136
+ });
137
+ expect(onRestore).toHaveBeenCalledWith({ email: 'alex@northwind-it.com' });
138
+ expect(result.current.submitFields()).toEqual({ form_attempt_id: '6f1c2a7e-3b9d-4e21-9a4f-0c8d5e7b1a22' });
139
+ });
140
+
141
+ it('prefills from a resume link and sends its token with the submit', async () => {
142
+ vi.useRealTimers();
143
+ // vitest.setup replaces `window.location` with a plain object; set its search directly.
144
+ const location = window.location as unknown as { search: string; pathname: string };
145
+ location.pathname = '/contact';
146
+ location.search = '?resume=k3Xq9vT2mB7wYp1sLr8dQa';
147
+ fetchSpy.mockImplementation(() =>
148
+ Promise.resolve(
149
+ new Response(JSON.stringify({ form_id: 'contact', values: { email: 'alex@northwind-it.com' } }), {
150
+ status: 200,
151
+ }),
152
+ ),
153
+ );
154
+ const onRestore = vi.fn();
155
+ const { result } = renderHook(() => useFormRescue({ formId: 'contact', fieldNames: FIELDS, onRestore }), {
156
+ wrapper,
157
+ });
158
+ await waitFor(() => expect(onRestore).toHaveBeenCalledWith({ email: 'alex@northwind-it.com' }));
159
+ expect(String(fetchSpy.mock.calls[0][0])).toBe('/api/contact/drafts/resume/k3Xq9vT2mB7wYp1sLr8dQa');
160
+ expect(result.current.submitFields()).toMatchObject({ form_resume_token: 'k3Xq9vT2mB7wYp1sLr8dQa' });
161
+ expect(captures.map(c => c.event)).toContain('form_resumed');
162
+ location.pathname = '/';
163
+ location.search = '';
164
+ });
165
+
166
+ it('does nothing when rescue is off', () => {
167
+ const { result } = renderHook(() => useFormRescue({ formId: null, fieldNames: FIELDS }), { wrapper });
168
+ act(() => {
169
+ result.current.track({ name: 'Alex', email: 'alex@northwind-it.com', message: '' }, 'name');
170
+ vi.advanceTimersByTime(FORM_RESCUE_DEBOUNCE_MS);
171
+ });
172
+ expect(fetchSpy).not.toHaveBeenCalled();
173
+ expect(result.current.submitFields()).toEqual({});
174
+ });
175
+
176
+ it('saves once more with keepalive when the tab is hidden', () => {
177
+ const { result } = renderHook(() => useFormRescue({ formId: 'contact', fieldNames: FIELDS }), { wrapper });
178
+ act(() => {
179
+ result.current.track({ name: 'Alex', email: '', message: '' }, 'name');
180
+ });
181
+ act(() => {
182
+ window.dispatchEvent(new Event('pagehide'));
183
+ });
184
+ const puts = fetchSpy.mock.calls.filter(([, init]) => init?.method === 'POST');
185
+ expect(puts).toHaveLength(1);
186
+ expect(puts[0][1]?.keepalive).toBe(true);
187
+ expect(captures.map(c => c.event)).toContain('form_abandoned');
188
+ });
189
+ });
@@ -41,3 +41,6 @@ export * from './use-scroll-to-hash';
41
41
  // Invisible bot-protection client primitive (honeypot ref + submit-timing).
42
42
  // Pairs with the server-safe decision fn in `utils/humanity-signals`.
43
43
  export * from './use-humanity-signals';
44
+ // Saves a half-filled public form so a visitor who leaves can be followed up with.
45
+ // Pairs with the server-safe rules in `utils/form-rescue`.
46
+ export * from './use-form-rescue';