@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.
- package/dist/{chunk-YNGAJGYB.cjs → chunk-6LWIJJJ6.cjs} +410 -6
- package/dist/{chunk-3PVOF3IB.js.map → chunk-6LWIJJJ6.cjs.map} +1 -1
- package/dist/{chunk-XLJNYTUW.cjs → chunk-6YSDCC3J.cjs} +56 -55
- package/dist/chunk-6YSDCC3J.cjs.map +1 -0
- package/dist/{chunk-GV7R2NTS.cjs → chunk-76DQOFZG.cjs} +23 -23
- package/dist/{chunk-GV7R2NTS.cjs.map → chunk-76DQOFZG.cjs.map} +1 -1
- package/dist/{chunk-UUOBCUHW.cjs → chunk-7ALCRUZ3.cjs} +3 -3
- package/dist/{chunk-UUOBCUHW.cjs.map → chunk-7ALCRUZ3.cjs.map} +1 -1
- package/dist/{chunk-PWPYCJ33.js → chunk-7RQZ2FTF.js} +2 -2
- package/dist/{chunk-XDTU23P4.js → chunk-ALSCESHG.js} +2 -2
- package/dist/{chunk-HVEX4BSC.cjs → chunk-AUQ4UITE.cjs} +60 -45
- package/dist/chunk-AUQ4UITE.cjs.map +1 -0
- package/dist/{chunk-CBCETEB4.cjs → chunk-E6B34U5Q.cjs} +6 -6
- package/dist/{chunk-CBCETEB4.cjs.map → chunk-E6B34U5Q.cjs.map} +1 -1
- package/dist/{chunk-H54F476P.cjs → chunk-FK3VSQ35.cjs} +3 -3
- package/dist/{chunk-H54F476P.cjs.map → chunk-FK3VSQ35.cjs.map} +1 -1
- package/dist/{chunk-HOEVEB7N.cjs → chunk-GCOVKIE7.cjs} +40 -29
- package/dist/chunk-GCOVKIE7.cjs.map +1 -0
- package/dist/{chunk-42OVTTOM.js → chunk-GIYAUKCY.js} +18 -3
- package/dist/{chunk-42OVTTOM.js.map → chunk-GIYAUKCY.js.map} +1 -1
- package/dist/{chunk-TQPKNG2E.js → chunk-INVZNRWB.js} +3 -3
- package/dist/{chunk-ZRIVCLPR.js → chunk-JD5TKRCU.js} +1 -1
- package/dist/chunk-JD5TKRCU.js.map +1 -0
- package/dist/{chunk-IBGHJLNT.js → chunk-JF4ZQZZJ.js} +2 -2
- package/dist/{chunk-SNWAN52K.cjs → chunk-JJDPSYBV.cjs} +3 -3
- package/dist/{chunk-SNWAN52K.cjs.map → chunk-JJDPSYBV.cjs.map} +1 -1
- package/dist/{chunk-D55IENZJ.cjs → chunk-LCMT2GZK.cjs} +1 -1
- package/dist/chunk-LCMT2GZK.cjs.map +1 -0
- package/dist/{chunk-AL4I5HHB.js → chunk-MMDGSGVE.js} +4 -4
- package/dist/{chunk-O5PYXQ6H.js → chunk-N3Q4FMMB.js} +3 -3
- package/dist/{chunk-RD3I5PBB.cjs → chunk-NUVRMZW5.cjs} +15 -15
- package/dist/{chunk-RD3I5PBB.cjs.map → chunk-NUVRMZW5.cjs.map} +1 -1
- package/dist/{chunk-T3KXCOB2.js → chunk-OIE6GKS3.js} +2 -2
- package/dist/{chunk-TFSGYKWG.js → chunk-OZL6FSB2.js} +28 -17
- package/dist/chunk-OZL6FSB2.js.map +1 -0
- package/dist/{chunk-NETIHUEY.cjs → chunk-P2A3KAYW.cjs} +10 -10
- package/dist/{chunk-NETIHUEY.cjs.map → chunk-P2A3KAYW.cjs.map} +1 -1
- package/dist/{chunk-6S2KLT7O.js → chunk-Q4KRZOQR.js} +4 -4
- package/dist/{chunk-BODMICIH.cjs → chunk-S7W7MTBM.cjs} +40 -40
- package/dist/{chunk-BODMICIH.cjs.map → chunk-S7W7MTBM.cjs.map} +1 -1
- package/dist/{chunk-3PVOF3IB.js → chunk-SGK4Q2W2.js} +407 -3
- package/dist/chunk-SGK4Q2W2.js.map +1 -0
- package/dist/{chunk-VZRVQEMT.cjs → chunk-SYTDEWZJ.cjs} +7 -7
- package/dist/{chunk-VZRVQEMT.cjs.map → chunk-SYTDEWZJ.cjs.map} +1 -1
- package/dist/{chunk-Z6E45TJM.js → chunk-TDCIGINK.js} +5 -5
- package/dist/{chunk-UOQRNZIB.cjs → chunk-TQATQ4CX.cjs} +17 -17
- package/dist/{chunk-UOQRNZIB.cjs.map → chunk-TQATQ4CX.cjs.map} +1 -1
- package/dist/{chunk-PSL2WBEO.cjs → chunk-TT6O7BUZ.cjs} +33 -33
- package/dist/{chunk-PSL2WBEO.cjs.map → chunk-TT6O7BUZ.cjs.map} +1 -1
- package/dist/{chunk-JREG4T6K.cjs → chunk-U7BJC4WF.cjs} +3 -3
- package/dist/{chunk-JREG4T6K.cjs.map → chunk-U7BJC4WF.cjs.map} +1 -1
- package/dist/{chunk-EKABOXNB.js → chunk-UIKMPCHW.js} +2 -2
- package/dist/{chunk-7KM34VWL.js → chunk-XMJT3QZ2.js} +3 -3
- package/dist/{chunk-4AZWVS7F.js → chunk-XSOL5ISO.js} +7 -6
- package/dist/{chunk-4AZWVS7F.js.map → chunk-XSOL5ISO.js.map} +1 -1
- package/dist/{chunk-KSBDARII.js → chunk-Y2ASYQ3P.js} +5 -5
- package/dist/components/case-studies/index.cjs +17 -14
- package/dist/components/case-studies/index.cjs.map +1 -1
- package/dist/components/case-studies/index.js +8 -5
- package/dist/components/case-studies/index.js.map +1 -1
- package/dist/components/case-studies/share-experience-section.d.ts.map +1 -1
- package/dist/components/contact/contact-form.d.ts +7 -1
- package/dist/components/contact/contact-form.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +8 -8
- package/dist/components/docs/index.js +7 -7
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/features/waitlist-form.d.ts +7 -2
- package/dist/components/features/waitlist-form.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +54 -52
- package/dist/components/help-center-pages/index.cjs.map +1 -1
- package/dist/components/help-center-pages/index.js +17 -15
- package/dist/components/help-center-pages/index.js.map +1 -1
- package/dist/components/help-center-pages/trust-center-sections.d.ts.map +1 -1
- package/dist/components/index.cjs +35 -35
- package/dist/components/index.js +14 -14
- package/dist/components/meeting-scheduler/booking-form.d.ts +7 -1
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +53 -31
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.d.ts +5 -1
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +31 -9
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +7 -7
- package/dist/components/navigation/index.js +6 -6
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/tickets/help-center-create-form.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +8 -8
- package/dist/components/tickets/index.js +7 -7
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/contexts/endpoints-runtime-context.d.ts +5 -0
- package/dist/contexts/endpoints-runtime-context.d.ts.map +1 -1
- package/dist/contexts/index.cjs +2 -2
- package/dist/contexts/index.js +1 -1
- package/dist/hooks/index.cjs +9 -3
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -2
- package/dist/hooks/use-form-rescue.d.ts +43 -0
- package/dist/hooks/use-form-rescue.d.ts.map +1 -0
- package/dist/hooks/use-rescued-form.d.ts +28 -0
- package/dist/hooks/use-rescued-form.d.ts.map +1 -0
- package/dist/index.cjs +60 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +59 -7
- package/dist/index.js.map +1 -1
- package/dist/utils/form-rescue.cjs +142 -0
- package/dist/utils/form-rescue.cjs.map +1 -0
- package/dist/utils/form-rescue.d.ts +133 -0
- package/dist/utils/form-rescue.d.ts.map +1 -0
- package/dist/utils/form-rescue.js +118 -0
- package/dist/utils/form-rescue.js.map +1 -0
- package/dist/utils/index.cjs +138 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +116 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +7 -1
- package/src/components/case-studies/share-experience-section.tsx +2 -0
- package/src/components/contact/contact-form.tsx +33 -12
- package/src/components/features/waitlist-form.tsx +27 -2
- package/src/components/help-center-pages/trust-center-sections.tsx +2 -0
- package/src/components/meeting-scheduler/booking-form.tsx +30 -2
- package/src/components/meeting-scheduler/index.tsx +10 -1
- package/src/components/tickets/help-center-create-form.tsx +2 -0
- package/src/contexts/endpoints-runtime-context.tsx +5 -0
- package/src/hooks/__tests__/use-form-rescue.test.tsx +189 -0
- package/src/hooks/__tests__/use-rescued-form.test.tsx +91 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/use-form-rescue.ts +330 -0
- package/src/hooks/use-rescued-form.ts +71 -0
- package/src/utils/__tests__/form-rescue.test.ts +124 -0
- package/src/utils/form-rescue.ts +237 -0
- package/src/utils/index.ts +5 -0
- package/dist/chunk-D55IENZJ.cjs.map +0 -1
- package/dist/chunk-HOEVEB7N.cjs.map +0 -1
- package/dist/chunk-HVEX4BSC.cjs.map +0 -1
- package/dist/chunk-TFSGYKWG.js.map +0 -1
- package/dist/chunk-XLJNYTUW.cjs.map +0 -1
- package/dist/chunk-YNGAJGYB.cjs.map +0 -1
- package/dist/chunk-ZRIVCLPR.js.map +0 -1
- /package/dist/{chunk-PWPYCJ33.js.map → chunk-7RQZ2FTF.js.map} +0 -0
- /package/dist/{chunk-XDTU23P4.js.map → chunk-ALSCESHG.js.map} +0 -0
- /package/dist/{chunk-TQPKNG2E.js.map → chunk-INVZNRWB.js.map} +0 -0
- /package/dist/{chunk-IBGHJLNT.js.map → chunk-JF4ZQZZJ.js.map} +0 -0
- /package/dist/{chunk-AL4I5HHB.js.map → chunk-MMDGSGVE.js.map} +0 -0
- /package/dist/{chunk-O5PYXQ6H.js.map → chunk-N3Q4FMMB.js.map} +0 -0
- /package/dist/{chunk-T3KXCOB2.js.map → chunk-OIE6GKS3.js.map} +0 -0
- /package/dist/{chunk-6S2KLT7O.js.map → chunk-Q4KRZOQR.js.map} +0 -0
- /package/dist/{chunk-Z6E45TJM.js.map → chunk-TDCIGINK.js.map} +0 -0
- /package/dist/{chunk-EKABOXNB.js.map → chunk-UIKMPCHW.js.map} +0 -0
- /package/dist/{chunk-7KM34VWL.js.map → chunk-XMJT3QZ2.js.map} +0 -0
- /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.
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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
|
+
});
|