@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.
- package/dist/{chunk-PSL2WBEO.cjs → chunk-26NSIJMT.cjs} +33 -33
- package/dist/{chunk-PSL2WBEO.cjs.map → chunk-26NSIJMT.cjs.map} +1 -1
- package/dist/{chunk-UOQRNZIB.cjs → chunk-6QUKYN4K.cjs} +17 -17
- package/dist/{chunk-UOQRNZIB.cjs.map → chunk-6QUKYN4K.cjs.map} +1 -1
- package/dist/{chunk-AL4I5HHB.js → chunk-BF7GZ6BS.js} +4 -4
- package/dist/{chunk-CBCETEB4.cjs → chunk-DRURJCEY.cjs} +6 -6
- package/dist/{chunk-CBCETEB4.cjs.map → chunk-DRURJCEY.cjs.map} +1 -1
- package/dist/{chunk-GV7R2NTS.cjs → chunk-G262S3Q5.cjs} +23 -23
- package/dist/{chunk-GV7R2NTS.cjs.map → chunk-G262S3Q5.cjs.map} +1 -1
- package/dist/{chunk-HVEX4BSC.cjs → chunk-HJ532KQR.cjs} +60 -45
- package/dist/chunk-HJ532KQR.cjs.map +1 -0
- package/dist/{chunk-TQPKNG2E.js → chunk-HRM6242Y.js} +3 -3
- package/dist/{chunk-HOEVEB7N.cjs → chunk-IFQMIYFO.cjs} +50 -23
- package/dist/chunk-IFQMIYFO.cjs.map +1 -0
- package/dist/{chunk-Z6E45TJM.js → chunk-IG7M3XKX.js} +5 -5
- package/dist/{chunk-EKABOXNB.js → chunk-II6ABVLK.js} +2 -2
- package/dist/{chunk-ZRIVCLPR.js → chunk-JD5TKRCU.js} +1 -1
- package/dist/chunk-JD5TKRCU.js.map +1 -0
- package/dist/{chunk-SNWAN52K.cjs → chunk-K365MY6G.cjs} +3 -3
- package/dist/{chunk-SNWAN52K.cjs.map → chunk-K365MY6G.cjs.map} +1 -1
- package/dist/{chunk-NETIHUEY.cjs → chunk-KV7W5T6U.cjs} +10 -10
- package/dist/{chunk-NETIHUEY.cjs.map → chunk-KV7W5T6U.cjs.map} +1 -1
- package/dist/{chunk-H54F476P.cjs → chunk-KYF5EONP.cjs} +3 -3
- package/dist/{chunk-H54F476P.cjs.map → chunk-KYF5EONP.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-TFSGYKWG.js → chunk-LMUZZUYC.js} +34 -7
- package/dist/chunk-LMUZZUYC.js.map +1 -0
- package/dist/{chunk-XLJNYTUW.cjs → chunk-N6TC6IPV.cjs} +56 -55
- package/dist/chunk-N6TC6IPV.cjs.map +1 -0
- package/dist/{chunk-IBGHJLNT.js → chunk-OGOMAK7B.js} +2 -2
- package/dist/{chunk-PWPYCJ33.js → chunk-P4GUIOLP.js} +2 -2
- package/dist/{chunk-7KM34VWL.js → chunk-RRU46J3N.js} +3 -3
- package/dist/{chunk-RD3I5PBB.cjs → chunk-THSIFQGS.cjs} +15 -15
- package/dist/{chunk-RD3I5PBB.cjs.map → chunk-THSIFQGS.cjs.map} +1 -1
- package/dist/{chunk-4AZWVS7F.js → chunk-TTHBFT7Q.js} +7 -6
- package/dist/{chunk-4AZWVS7F.js.map → chunk-TTHBFT7Q.js.map} +1 -1
- package/dist/{chunk-YNGAJGYB.cjs → chunk-TZP2OXHG.cjs} +346 -6
- package/dist/chunk-TZP2OXHG.cjs.map +1 -0
- package/dist/{chunk-VZRVQEMT.cjs → chunk-U2H3C4NT.cjs} +7 -7
- package/dist/{chunk-VZRVQEMT.cjs.map → chunk-U2H3C4NT.cjs.map} +1 -1
- package/dist/{chunk-KSBDARII.js → chunk-UFMXQKVT.js} +5 -5
- package/dist/{chunk-42OVTTOM.js → chunk-UMTXGEPU.js} +18 -3
- package/dist/{chunk-42OVTTOM.js.map → chunk-UMTXGEPU.js.map} +1 -1
- package/dist/{chunk-3PVOF3IB.js → chunk-UTHZ7DLN.js} +343 -3
- package/dist/{chunk-3PVOF3IB.js.map → chunk-UTHZ7DLN.js.map} +1 -1
- package/dist/{chunk-UUOBCUHW.cjs → chunk-UXT5EK27.cjs} +3 -3
- package/dist/{chunk-UUOBCUHW.cjs.map → chunk-UXT5EK27.cjs.map} +1 -1
- package/dist/{chunk-T3KXCOB2.js → chunk-VK5737GE.js} +2 -2
- package/dist/{chunk-O5PYXQ6H.js → chunk-VKLM5UKG.js} +3 -3
- package/dist/{chunk-6S2KLT7O.js → chunk-X2DR3XNN.js} +4 -4
- package/dist/{chunk-JREG4T6K.cjs → chunk-YCYOZAB5.cjs} +3 -3
- package/dist/{chunk-JREG4T6K.cjs.map → chunk-YCYOZAB5.cjs.map} +1 -1
- package/dist/{chunk-XDTU23P4.js → chunk-YWQVL5VJ.js} +2 -2
- package/dist/{chunk-BODMICIH.cjs → chunk-Z5K4IQZU.cjs} +40 -40
- package/dist/{chunk-BODMICIH.cjs.map → chunk-Z5K4IQZU.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +15 -14
- package/dist/components/case-studies/index.cjs.map +1 -1
- package/dist/components/case-studies/index.js +6 -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 +6 -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 +53 -52
- package/dist/components/help-center-pages/index.cjs.map +1 -1
- package/dist/components/help-center-pages/index.js +16 -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 +4 -1
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +72 -37
- 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 +45 -10
- 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 +5 -3
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +4 -2
- package/dist/hooks/use-form-rescue.d.ts +37 -0
- package/dist/hooks/use-form-rescue.d.ts.map +1 -0
- package/dist/index.cjs +52 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +51 -7
- package/dist/index.js.map +1 -1
- package/dist/utils/form-rescue.cjs +123 -0
- package/dist/utils/form-rescue.cjs.map +1 -0
- package/dist/utils/form-rescue.d.ts +105 -0
- package/dist/utils/form-rescue.d.ts.map +1 -0
- package/dist/utils/form-rescue.js +101 -0
- package/dist/utils/form-rescue.js.map +1 -0
- package/dist/utils/index.cjs +119 -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 +99 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +7 -1
- package/src/components/case-studies/share-experience-section.tsx +1 -0
- package/src/components/contact/contact-form.tsx +38 -2
- package/src/components/features/waitlist-form.tsx +27 -2
- package/src/components/help-center-pages/trust-center-sections.tsx +1 -0
- package/src/components/meeting-scheduler/booking-form.tsx +14 -0
- package/src/components/meeting-scheduler/index.tsx +38 -3
- 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/index.ts +3 -0
- package/src/hooks/use-form-rescue.ts +315 -0
- package/src/utils/__tests__/form-rescue.test.ts +106 -0
- package/src/utils/form-rescue.ts +198 -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-AL4I5HHB.js.map → chunk-BF7GZ6BS.js.map} +0 -0
- /package/dist/{chunk-TQPKNG2E.js.map → chunk-HRM6242Y.js.map} +0 -0
- /package/dist/{chunk-Z6E45TJM.js.map → chunk-IG7M3XKX.js.map} +0 -0
- /package/dist/{chunk-EKABOXNB.js.map → chunk-II6ABVLK.js.map} +0 -0
- /package/dist/{chunk-IBGHJLNT.js.map → chunk-OGOMAK7B.js.map} +0 -0
- /package/dist/{chunk-PWPYCJ33.js.map → chunk-P4GUIOLP.js.map} +0 -0
- /package/dist/{chunk-7KM34VWL.js.map → chunk-RRU46J3N.js.map} +0 -0
- /package/dist/{chunk-KSBDARII.js.map → chunk-UFMXQKVT.js.map} +0 -0
- /package/dist/{chunk-T3KXCOB2.js.map → chunk-VK5737GE.js.map} +0 -0
- /package/dist/{chunk-O5PYXQ6H.js.map → chunk-VKLM5UKG.js.map} +0 -0
- /package/dist/{chunk-6S2KLT7O.js.map → chunk-X2DR3XNN.js.map} +0 -0
- /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.
|
|
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
|
-
|
|
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
|
|
@@ -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
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -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';
|