@flamingo-stack/openframe-frontend-core 0.0.672 → 0.0.673-2406.7239.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 (164) hide show
  1. package/dist/{chunk-YNGAJGYB.cjs → chunk-6LWIJJJ6.cjs} +410 -6
  2. package/dist/{chunk-3PVOF3IB.js.map → chunk-6LWIJJJ6.cjs.map} +1 -1
  3. package/dist/{chunk-XLJNYTUW.cjs → chunk-6YSDCC3J.cjs} +56 -55
  4. package/dist/chunk-6YSDCC3J.cjs.map +1 -0
  5. package/dist/{chunk-GV7R2NTS.cjs → chunk-76DQOFZG.cjs} +23 -23
  6. package/dist/{chunk-GV7R2NTS.cjs.map → chunk-76DQOFZG.cjs.map} +1 -1
  7. package/dist/{chunk-UUOBCUHW.cjs → chunk-7ALCRUZ3.cjs} +3 -3
  8. package/dist/{chunk-UUOBCUHW.cjs.map → chunk-7ALCRUZ3.cjs.map} +1 -1
  9. package/dist/{chunk-PWPYCJ33.js → chunk-7RQZ2FTF.js} +2 -2
  10. package/dist/{chunk-XDTU23P4.js → chunk-ALSCESHG.js} +2 -2
  11. package/dist/{chunk-HVEX4BSC.cjs → chunk-AUQ4UITE.cjs} +60 -45
  12. package/dist/chunk-AUQ4UITE.cjs.map +1 -0
  13. package/dist/{chunk-CBCETEB4.cjs → chunk-E6B34U5Q.cjs} +6 -6
  14. package/dist/{chunk-CBCETEB4.cjs.map → chunk-E6B34U5Q.cjs.map} +1 -1
  15. package/dist/{chunk-H54F476P.cjs → chunk-FK3VSQ35.cjs} +3 -3
  16. package/dist/{chunk-H54F476P.cjs.map → chunk-FK3VSQ35.cjs.map} +1 -1
  17. package/dist/{chunk-HOEVEB7N.cjs → chunk-GCOVKIE7.cjs} +40 -29
  18. package/dist/chunk-GCOVKIE7.cjs.map +1 -0
  19. package/dist/{chunk-42OVTTOM.js → chunk-GIYAUKCY.js} +18 -3
  20. package/dist/{chunk-42OVTTOM.js.map → chunk-GIYAUKCY.js.map} +1 -1
  21. package/dist/{chunk-TQPKNG2E.js → chunk-INVZNRWB.js} +3 -3
  22. package/dist/{chunk-ZRIVCLPR.js → chunk-JD5TKRCU.js} +1 -1
  23. package/dist/chunk-JD5TKRCU.js.map +1 -0
  24. package/dist/{chunk-IBGHJLNT.js → chunk-JF4ZQZZJ.js} +2 -2
  25. package/dist/{chunk-SNWAN52K.cjs → chunk-JJDPSYBV.cjs} +3 -3
  26. package/dist/{chunk-SNWAN52K.cjs.map → chunk-JJDPSYBV.cjs.map} +1 -1
  27. package/dist/{chunk-D55IENZJ.cjs → chunk-LCMT2GZK.cjs} +1 -1
  28. package/dist/chunk-LCMT2GZK.cjs.map +1 -0
  29. package/dist/{chunk-AL4I5HHB.js → chunk-MMDGSGVE.js} +4 -4
  30. package/dist/{chunk-O5PYXQ6H.js → chunk-N3Q4FMMB.js} +3 -3
  31. package/dist/{chunk-RD3I5PBB.cjs → chunk-NUVRMZW5.cjs} +15 -15
  32. package/dist/{chunk-RD3I5PBB.cjs.map → chunk-NUVRMZW5.cjs.map} +1 -1
  33. package/dist/{chunk-T3KXCOB2.js → chunk-OIE6GKS3.js} +2 -2
  34. package/dist/{chunk-TFSGYKWG.js → chunk-OZL6FSB2.js} +28 -17
  35. package/dist/chunk-OZL6FSB2.js.map +1 -0
  36. package/dist/{chunk-NETIHUEY.cjs → chunk-P2A3KAYW.cjs} +10 -10
  37. package/dist/{chunk-NETIHUEY.cjs.map → chunk-P2A3KAYW.cjs.map} +1 -1
  38. package/dist/{chunk-6S2KLT7O.js → chunk-Q4KRZOQR.js} +4 -4
  39. package/dist/{chunk-BODMICIH.cjs → chunk-S7W7MTBM.cjs} +40 -40
  40. package/dist/{chunk-BODMICIH.cjs.map → chunk-S7W7MTBM.cjs.map} +1 -1
  41. package/dist/{chunk-3PVOF3IB.js → chunk-SGK4Q2W2.js} +407 -3
  42. package/dist/chunk-SGK4Q2W2.js.map +1 -0
  43. package/dist/{chunk-VZRVQEMT.cjs → chunk-SYTDEWZJ.cjs} +7 -7
  44. package/dist/{chunk-VZRVQEMT.cjs.map → chunk-SYTDEWZJ.cjs.map} +1 -1
  45. package/dist/{chunk-Z6E45TJM.js → chunk-TDCIGINK.js} +5 -5
  46. package/dist/{chunk-UOQRNZIB.cjs → chunk-TQATQ4CX.cjs} +17 -17
  47. package/dist/{chunk-UOQRNZIB.cjs.map → chunk-TQATQ4CX.cjs.map} +1 -1
  48. package/dist/{chunk-PSL2WBEO.cjs → chunk-TT6O7BUZ.cjs} +33 -33
  49. package/dist/{chunk-PSL2WBEO.cjs.map → chunk-TT6O7BUZ.cjs.map} +1 -1
  50. package/dist/{chunk-JREG4T6K.cjs → chunk-U7BJC4WF.cjs} +3 -3
  51. package/dist/{chunk-JREG4T6K.cjs.map → chunk-U7BJC4WF.cjs.map} +1 -1
  52. package/dist/{chunk-EKABOXNB.js → chunk-UIKMPCHW.js} +2 -2
  53. package/dist/{chunk-7KM34VWL.js → chunk-XMJT3QZ2.js} +3 -3
  54. package/dist/{chunk-4AZWVS7F.js → chunk-XSOL5ISO.js} +7 -6
  55. package/dist/{chunk-4AZWVS7F.js.map → chunk-XSOL5ISO.js.map} +1 -1
  56. package/dist/{chunk-KSBDARII.js → chunk-Y2ASYQ3P.js} +5 -5
  57. package/dist/components/case-studies/index.cjs +17 -14
  58. package/dist/components/case-studies/index.cjs.map +1 -1
  59. package/dist/components/case-studies/index.js +8 -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 +7 -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 +54 -52
  77. package/dist/components/help-center-pages/index.cjs.map +1 -1
  78. package/dist/components/help-center-pages/index.js +17 -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 +7 -1
  84. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  85. package/dist/components/meeting-scheduler/index.cjs +53 -31
  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 +31 -9
  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 +9 -3
  105. package/dist/hooks/index.cjs.map +1 -1
  106. package/dist/hooks/index.d.ts +2 -0
  107. package/dist/hooks/index.d.ts.map +1 -1
  108. package/dist/hooks/index.js +8 -2
  109. package/dist/hooks/use-form-rescue.d.ts +43 -0
  110. package/dist/hooks/use-form-rescue.d.ts.map +1 -0
  111. package/dist/hooks/use-rescued-form.d.ts +28 -0
  112. package/dist/hooks/use-rescued-form.d.ts.map +1 -0
  113. package/dist/index.cjs +60 -8
  114. package/dist/index.cjs.map +1 -1
  115. package/dist/index.js +59 -7
  116. package/dist/index.js.map +1 -1
  117. package/dist/utils/form-rescue.cjs +142 -0
  118. package/dist/utils/form-rescue.cjs.map +1 -0
  119. package/dist/utils/form-rescue.d.ts +133 -0
  120. package/dist/utils/form-rescue.d.ts.map +1 -0
  121. package/dist/utils/form-rescue.js +118 -0
  122. package/dist/utils/form-rescue.js.map +1 -0
  123. package/dist/utils/index.cjs +138 -0
  124. package/dist/utils/index.cjs.map +1 -1
  125. package/dist/utils/index.d.ts +1 -0
  126. package/dist/utils/index.d.ts.map +1 -1
  127. package/dist/utils/index.js +116 -1
  128. package/dist/utils/index.js.map +1 -1
  129. package/package.json +7 -1
  130. package/src/components/case-studies/share-experience-section.tsx +2 -0
  131. package/src/components/contact/contact-form.tsx +33 -12
  132. package/src/components/features/waitlist-form.tsx +27 -2
  133. package/src/components/help-center-pages/trust-center-sections.tsx +2 -0
  134. package/src/components/meeting-scheduler/booking-form.tsx +30 -2
  135. package/src/components/meeting-scheduler/index.tsx +10 -1
  136. package/src/components/tickets/help-center-create-form.tsx +2 -0
  137. package/src/contexts/endpoints-runtime-context.tsx +5 -0
  138. package/src/hooks/__tests__/use-form-rescue.test.tsx +189 -0
  139. package/src/hooks/__tests__/use-rescued-form.test.tsx +91 -0
  140. package/src/hooks/index.ts +4 -0
  141. package/src/hooks/use-form-rescue.ts +330 -0
  142. package/src/hooks/use-rescued-form.ts +71 -0
  143. package/src/utils/__tests__/form-rescue.test.ts +124 -0
  144. package/src/utils/form-rescue.ts +237 -0
  145. package/src/utils/index.ts +5 -0
  146. package/dist/chunk-D55IENZJ.cjs.map +0 -1
  147. package/dist/chunk-HOEVEB7N.cjs.map +0 -1
  148. package/dist/chunk-HVEX4BSC.cjs.map +0 -1
  149. package/dist/chunk-TFSGYKWG.js.map +0 -1
  150. package/dist/chunk-XLJNYTUW.cjs.map +0 -1
  151. package/dist/chunk-YNGAJGYB.cjs.map +0 -1
  152. package/dist/chunk-ZRIVCLPR.js.map +0 -1
  153. /package/dist/{chunk-PWPYCJ33.js.map → chunk-7RQZ2FTF.js.map} +0 -0
  154. /package/dist/{chunk-XDTU23P4.js.map → chunk-ALSCESHG.js.map} +0 -0
  155. /package/dist/{chunk-TQPKNG2E.js.map → chunk-INVZNRWB.js.map} +0 -0
  156. /package/dist/{chunk-IBGHJLNT.js.map → chunk-JF4ZQZZJ.js.map} +0 -0
  157. /package/dist/{chunk-AL4I5HHB.js.map → chunk-MMDGSGVE.js.map} +0 -0
  158. /package/dist/{chunk-O5PYXQ6H.js.map → chunk-N3Q4FMMB.js.map} +0 -0
  159. /package/dist/{chunk-T3KXCOB2.js.map → chunk-OIE6GKS3.js.map} +0 -0
  160. /package/dist/{chunk-6S2KLT7O.js.map → chunk-Q4KRZOQR.js.map} +0 -0
  161. /package/dist/{chunk-Z6E45TJM.js.map → chunk-TDCIGINK.js.map} +0 -0
  162. /package/dist/{chunk-EKABOXNB.js.map → chunk-UIKMPCHW.js.map} +0 -0
  163. /package/dist/{chunk-7KM34VWL.js.map → chunk-XMJT3QZ2.js.map} +0 -0
  164. /package/dist/{chunk-KSBDARII.js.map → chunk-Y2ASYQ3P.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.7239.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",
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import type React from 'react';
4
+ import { RESCUE_FORMS } from '../../utils/form-rescue';
4
5
  import { ContactForm, type ContactFormProps } from '../contact';
5
6
  import { G2Icon, CapterraIcon, TrustpilotIcon, GetAppIcon } from '../icons';
6
7
  import { BenefitCard, BenefitCardGrid } from '../ui';
@@ -93,6 +94,7 @@ const DEFAULT_CONTACT_FORM_PROPS = {
93
94
  buttonVariant: 'outline' as ContactFormProps['buttonVariant'],
94
95
  buttonClassName: 'w-full',
95
96
  successToastMessage: "Thank you! We'll reach out to schedule your case study.",
97
+ rescue: RESCUE_FORMS.caseStudyPitch,
96
98
  } satisfies Partial<ContactFormProps>;
97
99
 
98
100
  export function ShareExperienceSection({
@@ -22,10 +22,11 @@
22
22
  */
23
23
 
24
24
  import { zodResolver } from '@hookform/resolvers/zod';
25
- import { useState, type ReactNode } from 'react';
25
+ import { 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
28
  import { useHumanitySignals } from '../../hooks/use-humanity-signals';
29
+ import { useRescuedForm } from '../../hooks/use-rescued-form';
29
30
  import {
30
31
  ContactSchema,
31
32
  type ContactFormData,
@@ -33,6 +34,7 @@ import {
33
34
  referralSourceOptions,
34
35
  defaultHelpCategoryOptions,
35
36
  } from '../../schemas/contact-schema';
37
+ import type { FormRescueDefinition } 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,11 @@ export interface ContactFormProps {
130
132
  submitSuccessLabel?: string;
131
133
  successRedirectUrl?: string;
132
134
  successToastMessage?: string;
135
+ /** Form rescue (a half-filled form is saved so the team can follow up): the
136
+ * form's definition (`RESCUE_FORMS.contact`, or the host's own
137
+ * `defineRescueForm`). OPT-IN: omitted or `null` saves nothing, so a host that
138
+ * has not chosen rescue never starts storing what visitors type. */
139
+ rescue?: FormRescueDefinition | null;
133
140
  }
134
141
 
135
142
  export function ContactForm({
@@ -155,6 +162,7 @@ export function ContactForm({
155
162
  submitSuccessLabel = 'Message Sent!',
156
163
  successRedirectUrl = '/blog#community',
157
164
  successToastMessage = 'Redirecting you to join our community...',
165
+ rescue: rescueForm = null,
158
166
  }: ContactFormProps = {}) {
159
167
  // Attachments staging — same hook the chat composer + ticket
160
168
  // detail-drawer composer use. Files upload to Supabase as soon as
@@ -185,13 +193,7 @@ export function ContactForm({
185
193
  // own their own UX (no "Message Sent!" button-label flicker).
186
194
  const isSuccess = onCustomSubmit ? false : builtInSubmission.isSuccess;
187
195
 
188
- const {
189
- register,
190
- handleSubmit,
191
- control,
192
- formState: { errors },
193
- reset,
194
- } = useForm<ContactFormData>({
196
+ const form = useForm<ContactFormData>({
195
197
  resolver: zodResolver(ContactSchema),
196
198
  defaultValues: {
197
199
  ...(prefilledReason && { helpCategory: prefilledReason }),
@@ -201,12 +203,30 @@ export function ContactForm({
201
203
  ...defaultValuesProp,
202
204
  },
203
205
  });
206
+ const {
207
+ register,
208
+ handleSubmit,
209
+ control,
210
+ formState: { errors },
211
+ reset,
212
+ } = form;
213
+
214
+ // Form rescue: only the fields the visitor can see are tracked, so a hidden
215
+ // prefilled value (a signed-in email, a fixed category) is never sent.
216
+ const rescueFieldNames = useMemo(
217
+ () =>
218
+ (['name', 'email', 'companySize', 'referralSource', 'helpCategory', 'message'] as const).filter(
219
+ field => !hideFields.includes(field),
220
+ ),
221
+ [hideFields],
222
+ );
223
+ const rescue = useRescuedForm(form, { rescue: rescueForm, fields: rescueFieldNames, getSignals });
204
224
 
205
225
  const handleFormSubmit = async (data: ContactFormData) => {
206
226
  if (isSubmitting) return;
207
227
  if (attachmentsEnabled && attachments.hasInflightUploads) return;
208
228
  try {
209
- const payload = { ...data, ...(rdtCid && { rdt_cid: rdtCid }), ...getSignals() };
229
+ const payload = { ...data, ...(rdtCid && { rdt_cid: rdtCid }), ...getSignals(), ...rescue.submitFields() };
210
230
  const readyAttachments = attachmentsEnabled ? attachments.readyAttachments : [];
211
231
  if (onCustomSubmit) {
212
232
  setCustomSubmitting(true);
@@ -218,6 +238,7 @@ export function ContactForm({
218
238
  } else {
219
239
  await builtInSubmission.submit(payload);
220
240
  }
241
+ rescue.complete();
221
242
  onSubmitSuccess?.();
222
243
  reset();
223
244
  resetSignals();
@@ -346,7 +367,7 @@ export function ContactForm({
346
367
  control={control}
347
368
  name="companySize"
348
369
  render={({ field }) => (
349
- <Select onValueChange={field.onChange} defaultValue={field.value}>
370
+ <Select onValueChange={field.onChange} value={field.value ?? ''}>
350
371
  <SelectTrigger
351
372
  id="companySize"
352
373
  aria-label="Company Size"
@@ -378,7 +399,7 @@ export function ContactForm({
378
399
  control={control}
379
400
  name="referralSource"
380
401
  render={({ field }) => (
381
- <Select onValueChange={field.onChange} defaultValue={field.value}>
402
+ <Select onValueChange={field.onChange} value={field.value ?? ''}>
382
403
  <SelectTrigger
383
404
  id="referralSource"
384
405
  aria-label="Referral Source"
@@ -416,7 +437,7 @@ export function ContactForm({
416
437
  control={control}
417
438
  name="helpCategory"
418
439
  render={({ field }) => (
419
- <Select onValueChange={field.onChange} defaultValue={field.value}>
440
+ <Select onValueChange={field.onChange} value={field.value ?? ''}>
420
441
  <SelectTrigger
421
442
  id="helpCategory"
422
443
  aria-label="Help Category"
@@ -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 { FormRescueDefinition } 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), e.g. `RESCUE_FORMS.waitlist`. OPT-IN: omitted or `null` saves nothing. */
70
+ rescue?: FormRescueDefinition | 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
+ rescue: rescueForm = 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
+ form: rescueForm,
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
@@ -34,6 +34,7 @@ import {
34
34
  type TrustFrameworkMonitoringEntry,
35
35
  } from '../../types/trust-center';
36
36
  import { getFlagFromCountryName } from '../../utils/country-phone-utils';
37
+ import { RESCUE_FORMS } from '../../utils/form-rescue';
37
38
  import { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';
38
39
  import { brandLogoForName } from '../chat/utils/icon-library';
39
40
  import { ContactForm } from '../contact/contact-form';
@@ -443,6 +444,7 @@ export function DocumentRequestModal({
443
444
  with access, usually within one business day.
444
445
  </p>
445
446
  <ContactForm
447
+ rescue={RESCUE_FORMS.trustCenterRequest}
446
448
  title=""
447
449
  noBorder
448
450
  noPadding
@@ -4,7 +4,8 @@ import { zodResolver } from '@hookform/resolvers/zod';
4
4
  import { Fragment, useEffect, useMemo, useState } from 'react';
5
5
  import type { FormEvent, ReactNode, Ref } from 'react';
6
6
  import { useForm, Controller } from 'react-hook-form';
7
- import type { Control, UseFormRegister } from 'react-hook-form';
7
+ import type { Control, Path, UseFormRegister } from 'react-hook-form';
8
+ import { useRescuedForm } from '../../hooks/use-rescued-form';
8
9
  import {
9
10
  BUILT_IN_BOOKING_FIELDS,
10
11
  type BuiltInBookingFieldName,
@@ -22,6 +23,7 @@ import {
22
23
  type BookingFormValues,
23
24
  } from '../../schemas/meeting-booking-schema';
24
25
  import { cn } from '../../utils/cn';
26
+ import type { FormRescueDefinition } from '../../utils/form-rescue';
25
27
  import { HUBSPOT_DO_NOT_COLLECT_FORM_PROPS } from '../../utils/hubspot-collected-forms';
26
28
  import {
27
29
  Button,
@@ -370,6 +372,11 @@ export interface BookingFormProps {
370
372
  /** From useHumanitySignals — parent owns the instance so it can resetSignals(). */
371
373
  honeypotInputProps: { ref: Ref<HTMLInputElement>; name: string };
372
374
  getSignals: () => Record<string, string | number>;
375
+ /** Form rescue for this form (`RESCUE_FORMS.meetingBooking`); `null` or omitted saves nothing.
376
+ * The attempt's keys ride the payload handed to `onSubmit`, so the parent closes it after
377
+ * the booking commits (`completeFormRescue`), in details-first too, where this form has
378
+ * unmounted by then. */
379
+ rescue?: FormRescueDefinition | null;
373
380
  }
374
381
 
375
382
  /**
@@ -402,6 +409,7 @@ export function BookingForm({
402
409
  onSubmit,
403
410
  honeypotInputProps,
404
411
  getSignals,
412
+ rescue = null,
405
413
  }: BookingFormProps) {
406
414
  const { formFields, legalConsent } = availability;
407
415
  // Every declared question resolved to a control (`resolveFormFieldControl`) —
@@ -431,6 +439,7 @@ export function BookingForm({
431
439
  handleSubmit,
432
440
  setValue,
433
441
  getValues,
442
+ watch,
434
443
  formState: { errors },
435
444
  } = useForm<BookingFormValues>({
436
445
  resolver: zodResolver(schema),
@@ -479,6 +488,24 @@ export function BookingForm({
479
488
  );
480
489
  }, [consentDefaults, priorConsents, getValues, setValue]);
481
490
 
491
+ // Form rescue: the built-ins by name, the declared questions under `formFields.<name>`.
492
+ const rescueFields = useMemo(
493
+ () => [...BUILT_IN_BOOKING_FIELDS.map(field => field.name), ...supportedFields.map(field => field.name)],
494
+ [supportedFields],
495
+ );
496
+ const formRescue = useRescuedForm(
497
+ { watch, getValues, setValue },
498
+ {
499
+ rescue,
500
+ fields: rescueFields,
501
+ getSignals,
502
+ fieldPath: name =>
503
+ (BUILT_IN_BOOKING_FIELDS.some(field => field.name === name)
504
+ ? name
505
+ : `formFields.${name}`) as Path<BookingFormValues>,
506
+ },
507
+ );
508
+
482
509
  const submitValid = handleSubmit(async data => {
483
510
  if (consentMissing) return; // the error is already on screen — see `submit`
484
511
  if (deferSlot) {
@@ -486,7 +513,7 @@ export function BookingForm({
486
513
  // must do so synchronously in this call — this form and its honeypot are
487
514
  // still mounted here, and `getSignals()` reads a detached ref once they
488
515
  // unmount, which would silently disable the decoy.
489
- await onSubmit({ ...data, meetingId, [HOST_CONSENT_KEY]: consented });
516
+ await onSubmit({ ...data, meetingId, [HOST_CONSENT_KEY]: consented, ...formRescue.submitFields() });
490
517
  return;
491
518
  }
492
519
  await onSubmit({
@@ -497,6 +524,7 @@ export function BookingForm({
497
524
  durationMs,
498
525
  timezone,
499
526
  ...getSignals(),
527
+ ...formRescue.submitFields(),
500
528
  });
501
529
  });
502
530
 
@@ -42,6 +42,7 @@
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 { completeFormRescue } 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';
@@ -53,6 +54,7 @@ import {
53
54
  type MeetingHost,
54
55
  } from '../../schemas/meeting-booking-schema';
55
56
  import { cn } from '../../utils/cn';
57
+ import { FORM_RESCUE_ATTEMPT_FIELD, type FormRescueDefinition } from '../../utils/form-rescue';
56
58
  import { formatDurationCompact, formatDateWithTimezone } from '../../utils/format';
57
59
  import { Alert, AlertDescription, Button } from '../ui';
58
60
  import { BookingForm, BookingFormSkeleton, DEFAULT_SUBMIT_LABEL, type BookingFormProps } from './booking-form';
@@ -150,6 +152,9 @@ export interface HubSpotMeetingSchedulerProps {
150
152
  * can pass it across the RSC boundary where a component cannot.
151
153
  */
152
154
  detailsFormProps?: Pick<BookingFormProps, 'fieldRows' | 'consent'>;
155
+ /** Form rescue for the details form (`RESCUE_FORMS.meetingBooking`). OPT-IN:
156
+ * omitted or `null` saves nothing. */
157
+ rescue?: FormRescueDefinition | null;
153
158
  }
154
159
 
155
160
  type Step = 'slot' | 'details' | 'confirmed';
@@ -403,6 +408,7 @@ export function HubSpotMeetingScheduler({
403
408
  flow = DEFAULT_SCHEDULER_FLOW,
404
409
  detailsForm: DetailsForm = BookingForm,
405
410
  detailsFormProps,
411
+ rescue = null,
406
412
  }: HubSpotMeetingSchedulerProps) {
407
413
  const {
408
414
  availability,
@@ -642,6 +648,8 @@ export function HubSpotMeetingScheduler({
642
648
  return;
643
649
  }
644
650
  if (result.ok && result.confirmation) {
651
+ // The details form may have unmounted (details-first); its attempt id rides the payload.
652
+ completeFormRescue(rescue, payload[FORM_RESCUE_ATTEMPT_FIELD]);
645
653
  setConfirmation(result.confirmation);
646
654
  setStep('confirmed');
647
655
  onBooked?.(result.confirmation);
@@ -681,7 +689,7 @@ export function HubSpotMeetingScheduler({
681
689
  });
682
690
  }
683
691
  },
684
- [book, bookingError, detailsFirst, onBooked, refetchAvailability, resetSignals, toast],
692
+ [book, bookingError, detailsFirst, onBooked, refetchAvailability, rescue, resetSignals, toast],
685
693
  );
686
694
 
687
695
  const escapeHatch = fallbackUrl ? (
@@ -867,6 +875,7 @@ export function HubSpotMeetingScheduler({
867
875
  onSubmit={detailsFirst ? stashDetails : handleSubmit}
868
876
  honeypotInputProps={honeypotInputProps}
869
877
  getSignals={getSignals}
878
+ rescue={rescue}
870
879
  />
871
880
  </div>
872
881
  ) : (
@@ -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
+ rescue={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, RESCUE_FORMS } 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({ form: RESCUE_FORMS.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({ form: RESCUE_FORMS.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({ form: RESCUE_FORMS.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({ form: RESCUE_FORMS.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({ form: RESCUE_FORMS.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({ form: RESCUE_FORMS.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({ form: 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({ form: RESCUE_FORMS.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
+ });