@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { useForm } from 'react-hook-form';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { EndpointsRuntimeContext, type EndpointsRuntime } from '../../contexts/endpoints-runtime-context';
|
|
7
|
+
import { FORM_RESCUE_DEBOUNCE_MS, RESCUE_FORMS } from '../../utils/form-rescue';
|
|
8
|
+
import { useRescuedForm } from '../use-rescued-form';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The generic react-hook-form adapter is what every rescued form uses, so its
|
|
12
|
+
* contract is tested once here: edits are tracked (nested answers too), a
|
|
13
|
+
* restored draft fills only EMPTY fields, and hidden fields are never sent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const runtime: EndpointsRuntime = {
|
|
17
|
+
announcementsUrl: '/a',
|
|
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
|
+
interface Values {
|
|
28
|
+
name: string;
|
|
29
|
+
email: string;
|
|
30
|
+
formFields: { company: string };
|
|
31
|
+
secret: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function useFixture(defaults: Partial<Values> = {}) {
|
|
35
|
+
const form = useForm<Values>({
|
|
36
|
+
defaultValues: { name: '', email: '', formFields: { company: '' }, secret: 'hidden', ...defaults },
|
|
37
|
+
});
|
|
38
|
+
const rescue = useRescuedForm(form, {
|
|
39
|
+
rescue: RESCUE_FORMS.meetingBooking,
|
|
40
|
+
fields: ['name', 'email', 'company'],
|
|
41
|
+
fieldPath: name => (name === 'company' ? 'formFields.company' : (name as 'name' | 'email')),
|
|
42
|
+
});
|
|
43
|
+
return { form, rescue };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.useFakeTimers();
|
|
48
|
+
window.localStorage.clear();
|
|
49
|
+
vi.stubGlobal(
|
|
50
|
+
'fetch',
|
|
51
|
+
vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(new Response('{}', { status: 202 }))),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('useRescuedForm', () => {
|
|
61
|
+
it('tracks edits, nested answers included, and never a hidden field', () => {
|
|
62
|
+
const { result } = renderHook(() => useFixture(), { wrapper });
|
|
63
|
+
act(() => {
|
|
64
|
+
result.current.form.setValue('email', 'alex@northwind-it.com');
|
|
65
|
+
result.current.form.setValue('formFields.company', 'Northwind IT');
|
|
66
|
+
vi.advanceTimersByTime(FORM_RESCUE_DEBOUNCE_MS);
|
|
67
|
+
});
|
|
68
|
+
const calls = vi.mocked(fetch).mock.calls;
|
|
69
|
+
expect(calls).toHaveLength(1);
|
|
70
|
+
const body = JSON.parse(String(calls[0][1]?.body)) as { values: Record<string, string>; form_id: string };
|
|
71
|
+
expect(body.form_id).toBe('meeting_booking');
|
|
72
|
+
expect(body.values).toEqual({ email: 'alex@northwind-it.com', company: 'Northwind IT' });
|
|
73
|
+
expect(JSON.stringify(body)).not.toContain('hidden');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('restores a local draft into empty fields only, at their real paths', () => {
|
|
77
|
+
window.localStorage.setItem(
|
|
78
|
+
'form-rescue:v1:meeting_booking',
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
attemptId: '6f1c2a7e-3b9d-4e21-9a4f-0c8d5e7b1a22',
|
|
81
|
+
values: { name: 'Alex', email: 'alex@northwind-it.com', company: 'Northwind IT' },
|
|
82
|
+
savedAt: Date.now(),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
const { result } = renderHook(() => useFixture({ name: 'Typed Already' }), { wrapper });
|
|
86
|
+
expect(result.current.form.getValues('name')).toBe('Typed Already');
|
|
87
|
+
expect(result.current.form.getValues('email')).toBe('alex@northwind-it.com');
|
|
88
|
+
expect(result.current.form.getValues('formFields.company')).toBe('Northwind IT');
|
|
89
|
+
expect(result.current.rescue.submitFields()).toEqual({ form_attempt_id: '6f1c2a7e-3b9d-4e21-9a4f-0c8d5e7b1a22' });
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -41,3 +41,7 @@ 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';
|
|
47
|
+
export * from './use-rescued-form';
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import { useEndpointsRuntime } from '../contexts/endpoints-runtime-context';
|
|
5
|
+
import { contentFetch } from '../utils/embed-content-fetch';
|
|
6
|
+
import {
|
|
7
|
+
captureFormRescueEvent,
|
|
8
|
+
FORM_RESCUE_ATTEMPT_FIELD,
|
|
9
|
+
FORM_RESCUE_DEBOUNCE_MS,
|
|
10
|
+
FORM_RESCUE_EVENTS,
|
|
11
|
+
FORM_RESCUE_RESUME_FIELD,
|
|
12
|
+
FORM_RESCUE_RESUME_PARAM,
|
|
13
|
+
filledRescueFields,
|
|
14
|
+
isFormAttemptId,
|
|
15
|
+
isRescueEmail,
|
|
16
|
+
rescueCompletionPct,
|
|
17
|
+
sanitizeRescueFieldName,
|
|
18
|
+
sanitizeRescueValues,
|
|
19
|
+
type FormDraftProgress,
|
|
20
|
+
type FormDraftResumeResponse,
|
|
21
|
+
type FormDraftSaveRequest,
|
|
22
|
+
type FormRescueDefinition,
|
|
23
|
+
} from '../utils/form-rescue';
|
|
24
|
+
|
|
25
|
+
/** A local draft older than this is ignored and replaced. */
|
|
26
|
+
const LOCAL_DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
27
|
+
const STORAGE_PREFIX = 'form-rescue:v1:';
|
|
28
|
+
const UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'] as const;
|
|
29
|
+
|
|
30
|
+
interface LocalDraft {
|
|
31
|
+
attemptId: string;
|
|
32
|
+
values: Record<string, string>;
|
|
33
|
+
savedAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UseFormRescueOptions {
|
|
37
|
+
/** Which form this is (`defineRescueForm`); `null` turns rescue off. */
|
|
38
|
+
form: FormRescueDefinition | null;
|
|
39
|
+
/** Every field the visitor can see. Hidden fields are never listed, so never sent. */
|
|
40
|
+
fieldNames: readonly string[];
|
|
41
|
+
/** Called once after mount with values from a resume link, else from this device's local draft. */
|
|
42
|
+
onRestore?: (values: Record<string, string>) => void;
|
|
43
|
+
/** The form's humanity signals, so the draft endpoint can run the same bot gate as the submit. */
|
|
44
|
+
getSignals?: () => Record<string, string | number>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FormRescueHandle {
|
|
48
|
+
/** Report the form's current values after a change (`lastField` = the field just edited). */
|
|
49
|
+
track: (values: Record<string, unknown>, lastField?: string | null) => void;
|
|
50
|
+
/** Keys to spread into the SUBMIT body so the host closes this attempt's draft. */
|
|
51
|
+
submitFields: () => Record<string, string>;
|
|
52
|
+
/** Call after a successful submit: clears the local draft and starts a fresh attempt. */
|
|
53
|
+
complete: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function newAttemptId(): string {
|
|
57
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
|
58
|
+
// RFC 4122 v4 from getRandomValues for older browsers.
|
|
59
|
+
const bytes = new Uint8Array(16);
|
|
60
|
+
crypto.getRandomValues(bytes);
|
|
61
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
62
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
63
|
+
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
64
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readLocalDraft(formId: string): LocalDraft | null {
|
|
68
|
+
try {
|
|
69
|
+
const raw = window.localStorage.getItem(STORAGE_PREFIX + formId);
|
|
70
|
+
if (!raw) return null;
|
|
71
|
+
const parsed = JSON.parse(raw) as Partial<LocalDraft>;
|
|
72
|
+
if (!isFormAttemptId(parsed.attemptId) || typeof parsed.savedAt !== 'number') return null;
|
|
73
|
+
if (Date.now() - parsed.savedAt > LOCAL_DRAFT_TTL_MS) return null;
|
|
74
|
+
return { attemptId: parsed.attemptId, values: sanitizeRescueValues(parsed.values), savedAt: parsed.savedAt };
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeLocalDraft(formId: string, draft: LocalDraft): void {
|
|
81
|
+
try {
|
|
82
|
+
window.localStorage.setItem(STORAGE_PREFIX + formId, JSON.stringify(draft));
|
|
83
|
+
} catch {
|
|
84
|
+
// Storage full or blocked: the server draft still works.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clearLocalDraft(formId: string): void {
|
|
89
|
+
try {
|
|
90
|
+
window.localStorage.removeItem(STORAGE_PREFIX + formId);
|
|
91
|
+
} catch {
|
|
92
|
+
// Nothing to clear.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Close a rescue attempt after a successful submit, for a form that has already
|
|
98
|
+
* unmounted by then (the meeting scheduler's details-first flow): clears this
|
|
99
|
+
* device's draft and reports the submit. The hook's `complete` calls it too.
|
|
100
|
+
*/
|
|
101
|
+
export function completeFormRescue(form: FormRescueDefinition | null, attemptId?: unknown): void {
|
|
102
|
+
if (!form || typeof window === 'undefined') return;
|
|
103
|
+
const local = readLocalDraft(form.id);
|
|
104
|
+
captureFormRescueEvent(FORM_RESCUE_EVENTS.submitted, {
|
|
105
|
+
form_id: form.id,
|
|
106
|
+
attempt_id: isFormAttemptId(attemptId) ? attemptId : (local?.attemptId ?? null),
|
|
107
|
+
});
|
|
108
|
+
clearLocalDraft(form.id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readUtm(): FormDraftSaveRequest['utm'] {
|
|
112
|
+
try {
|
|
113
|
+
const params = new URLSearchParams(window.location.search);
|
|
114
|
+
const utm: NonNullable<FormDraftSaveRequest['utm']> = {};
|
|
115
|
+
for (const key of UTM_KEYS) {
|
|
116
|
+
const value = params.get(`utm_${key}`);
|
|
117
|
+
if (value) utm[key] = value.slice(0, 200);
|
|
118
|
+
}
|
|
119
|
+
return Object.keys(utm).length ? utm : undefined;
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* useFormRescue — saves a public form's progress so a visitor who leaves
|
|
127
|
+
* before submitting can be followed up with, and restores it when they return.
|
|
128
|
+
*
|
|
129
|
+
* Every public lead form calls it the same way (next to `useHumanitySignals`):
|
|
130
|
+
*
|
|
131
|
+
* const rescue = useFormRescue({ form: RESCUE_FORMS.waitlist, fieldNames, onRestore, getSignals })
|
|
132
|
+
* // on change: rescue.track(values, changedField)
|
|
133
|
+
* // submit: body = { ...data, ...getSignals(), ...rescue.submitFields() }
|
|
134
|
+
* // success: rescue.complete()
|
|
135
|
+
*
|
|
136
|
+
* Saves go to the host's `EndpointsRuntime.formDraftsUrl` (debounced, plus one
|
|
137
|
+
* final keepalive save when the tab is hidden). A host that does not provide
|
|
138
|
+
* the url gets the local draft only. Nothing here can block or delay a submit:
|
|
139
|
+
* every network and storage call is fire-and-forget and swallows its errors.
|
|
140
|
+
*/
|
|
141
|
+
export function useFormRescue({ form, fieldNames, onRestore, getSignals }: UseFormRescueOptions): FormRescueHandle {
|
|
142
|
+
const draftsUrl = useEndpointsRuntime()?.formDraftsUrl;
|
|
143
|
+
const formId = form?.id ?? null;
|
|
144
|
+
|
|
145
|
+
const attemptIdRef = useRef<string | null>(null);
|
|
146
|
+
const resumeTokenRef = useRef<string | null>(null);
|
|
147
|
+
const latestRef = useRef<FormDraftProgress | null>(null);
|
|
148
|
+
const dirtyRef = useRef(false);
|
|
149
|
+
const startedRef = useRef(false);
|
|
150
|
+
const leadCapturedRef = useRef(false);
|
|
151
|
+
const completedRef = useRef(false);
|
|
152
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
153
|
+
|
|
154
|
+
// Callers pass inline arrays and callbacks; read them through refs so the
|
|
155
|
+
// returned handle stays stable and effects run once. Written in an effect
|
|
156
|
+
// (declared first, so it runs before the restore below), never during render.
|
|
157
|
+
const fieldNamesRef = useRef(fieldNames);
|
|
158
|
+
const onRestoreRef = useRef(onRestore);
|
|
159
|
+
const getSignalsRef = useRef(getSignals);
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
fieldNamesRef.current = fieldNames;
|
|
162
|
+
onRestoreRef.current = onRestore;
|
|
163
|
+
getSignalsRef.current = getSignals;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const attemptId = useCallback((): string => {
|
|
167
|
+
if (!attemptIdRef.current) attemptIdRef.current = newAttemptId();
|
|
168
|
+
return attemptIdRef.current;
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
const send = useCallback(
|
|
172
|
+
(keepalive: boolean) => {
|
|
173
|
+
if (!formId || !draftsUrl || !dirtyRef.current || completedRef.current || !latestRef.current) return;
|
|
174
|
+
dirtyRef.current = false;
|
|
175
|
+
const body: FormDraftSaveRequest = {
|
|
176
|
+
attempt_id: attemptId(),
|
|
177
|
+
form_id: formId,
|
|
178
|
+
...latestRef.current,
|
|
179
|
+
source_path: typeof window !== 'undefined' ? window.location.pathname : '/',
|
|
180
|
+
utm: readUtm(),
|
|
181
|
+
};
|
|
182
|
+
try {
|
|
183
|
+
void contentFetch(draftsUrl, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({ ...body, ...(getSignalsRef.current?.() ?? {}) }),
|
|
187
|
+
keepalive,
|
|
188
|
+
}).catch(() => undefined);
|
|
189
|
+
} catch {
|
|
190
|
+
// A failed save is retried by the next change.
|
|
191
|
+
}
|
|
192
|
+
captureFormRescueEvent(FORM_RESCUE_EVENTS.progress, {
|
|
193
|
+
form_id: formId,
|
|
194
|
+
attempt_id: attemptId(),
|
|
195
|
+
fields_filled: body.fields_filled,
|
|
196
|
+
last_field: body.last_field,
|
|
197
|
+
completion_pct: body.completion_pct,
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
[attemptId, draftsUrl, formId],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Restore once on mount: a resume link wins over this device's local draft.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!formId || typeof window === 'undefined') return undefined;
|
|
206
|
+
let cancelled = false;
|
|
207
|
+
const token = new URLSearchParams(window.location.search).get(FORM_RESCUE_RESUME_PARAM);
|
|
208
|
+
|
|
209
|
+
const restoreLocal = () => {
|
|
210
|
+
const local = readLocalDraft(formId);
|
|
211
|
+
if (!local) return;
|
|
212
|
+
attemptIdRef.current = local.attemptId;
|
|
213
|
+
if (Object.keys(local.values).length) onRestoreRef.current?.(local.values);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (token && draftsUrl && /^[A-Za-z0-9_-]{16,128}$/.test(token)) {
|
|
217
|
+
contentFetch(`${draftsUrl}/resume/${encodeURIComponent(token)}`)
|
|
218
|
+
.then(async res => (res.ok ? ((await res.json()) as FormDraftResumeResponse) : null))
|
|
219
|
+
.then(data => {
|
|
220
|
+
if (cancelled) return;
|
|
221
|
+
if (!data || data.form_id !== formId) {
|
|
222
|
+
restoreLocal();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
resumeTokenRef.current = token;
|
|
226
|
+
onRestoreRef.current?.(sanitizeRescueValues(data.values));
|
|
227
|
+
captureFormRescueEvent(FORM_RESCUE_EVENTS.resumed, { form_id: formId, attempt_id: attemptId() });
|
|
228
|
+
})
|
|
229
|
+
.catch(() => {
|
|
230
|
+
if (!cancelled) restoreLocal();
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
restoreLocal();
|
|
234
|
+
}
|
|
235
|
+
return () => {
|
|
236
|
+
cancelled = true;
|
|
237
|
+
};
|
|
238
|
+
}, [attemptId, draftsUrl, formId]);
|
|
239
|
+
|
|
240
|
+
// Final save when the visitor hides or leaves the tab.
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (!formId || typeof document === 'undefined') return undefined;
|
|
243
|
+
const onHide = () => {
|
|
244
|
+
if (completedRef.current || !startedRef.current) return;
|
|
245
|
+
if (timerRef.current) {
|
|
246
|
+
clearTimeout(timerRef.current);
|
|
247
|
+
timerRef.current = null;
|
|
248
|
+
}
|
|
249
|
+
send(true);
|
|
250
|
+
captureFormRescueEvent(FORM_RESCUE_EVENTS.abandoned, {
|
|
251
|
+
form_id: formId,
|
|
252
|
+
attempt_id: attemptId(),
|
|
253
|
+
last_field: latestRef.current?.last_field ?? null,
|
|
254
|
+
completion_pct: latestRef.current?.completion_pct ?? 0,
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
const onVisibility = () => {
|
|
258
|
+
if (document.visibilityState === 'hidden') onHide();
|
|
259
|
+
};
|
|
260
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
261
|
+
window.addEventListener('pagehide', onHide);
|
|
262
|
+
return () => {
|
|
263
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
264
|
+
window.removeEventListener('pagehide', onHide);
|
|
265
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
266
|
+
};
|
|
267
|
+
}, [attemptId, formId, send]);
|
|
268
|
+
|
|
269
|
+
const track = useCallback(
|
|
270
|
+
(values: Record<string, unknown>, lastField?: string | null) => {
|
|
271
|
+
if (!formId || completedRef.current) return;
|
|
272
|
+
const names = fieldNamesRef.current;
|
|
273
|
+
const visible: Record<string, unknown> = {};
|
|
274
|
+
for (const name of names) visible[name] = values[name];
|
|
275
|
+
const filled = filledRescueFields(visible, names);
|
|
276
|
+
if (!filled.length) return;
|
|
277
|
+
|
|
278
|
+
if (!startedRef.current) {
|
|
279
|
+
startedRef.current = true;
|
|
280
|
+
captureFormRescueEvent(FORM_RESCUE_EVENTS.started, { form_id: formId, attempt_id: attemptId() });
|
|
281
|
+
}
|
|
282
|
+
const clean = sanitizeRescueValues(visible);
|
|
283
|
+
if (!leadCapturedRef.current && isRescueEmail(clean.email)) {
|
|
284
|
+
leadCapturedRef.current = true;
|
|
285
|
+
captureFormRescueEvent(FORM_RESCUE_EVENTS.leadCaptured, { form_id: formId, attempt_id: attemptId() });
|
|
286
|
+
}
|
|
287
|
+
latestRef.current = {
|
|
288
|
+
values: clean,
|
|
289
|
+
fields_filled: filled,
|
|
290
|
+
last_field: sanitizeRescueFieldName(lastField),
|
|
291
|
+
completion_pct: rescueCompletionPct(filled.length, names.length),
|
|
292
|
+
};
|
|
293
|
+
dirtyRef.current = true;
|
|
294
|
+
writeLocalDraft(formId, { attemptId: attemptId(), values: clean, savedAt: Date.now() });
|
|
295
|
+
|
|
296
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
297
|
+
timerRef.current = setTimeout(() => {
|
|
298
|
+
timerRef.current = null;
|
|
299
|
+
send(false);
|
|
300
|
+
}, FORM_RESCUE_DEBOUNCE_MS);
|
|
301
|
+
},
|
|
302
|
+
[attemptId, formId, send],
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const submitFields = useCallback((): Record<string, string> => {
|
|
306
|
+
if (!formId) return {};
|
|
307
|
+
return {
|
|
308
|
+
[FORM_RESCUE_ATTEMPT_FIELD]: attemptId(),
|
|
309
|
+
...(resumeTokenRef.current ? { [FORM_RESCUE_RESUME_FIELD]: resumeTokenRef.current } : {}),
|
|
310
|
+
};
|
|
311
|
+
}, [attemptId, formId]);
|
|
312
|
+
|
|
313
|
+
const complete = useCallback(() => {
|
|
314
|
+
if (!formId) return;
|
|
315
|
+
if (timerRef.current) {
|
|
316
|
+
clearTimeout(timerRef.current);
|
|
317
|
+
timerRef.current = null;
|
|
318
|
+
}
|
|
319
|
+
completeFormRescue(form, attemptId());
|
|
320
|
+
// A fresh attempt for a second submission from the same page.
|
|
321
|
+
attemptIdRef.current = null;
|
|
322
|
+
resumeTokenRef.current = null;
|
|
323
|
+
latestRef.current = null;
|
|
324
|
+
dirtyRef.current = false;
|
|
325
|
+
startedRef.current = false;
|
|
326
|
+
leadCapturedRef.current = false;
|
|
327
|
+
}, [attemptId, form, formId]);
|
|
328
|
+
|
|
329
|
+
return useMemo(() => ({ track, submitFields, complete }), [track, submitFields, complete]);
|
|
330
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import type { FieldValues, Path, PathValue, UseFormReturn } from 'react-hook-form';
|
|
5
|
+
import type { FormRescueDefinition } from '../utils/form-rescue';
|
|
6
|
+
import { useFormRescue, type FormRescueHandle } from './use-form-rescue';
|
|
7
|
+
|
|
8
|
+
export interface UseRescuedFormOptions<T extends FieldValues> {
|
|
9
|
+
/** The form's definition (`defineRescueForm` / `RESCUE_FORMS`); `null` turns rescue off. */
|
|
10
|
+
rescue: FormRescueDefinition | null;
|
|
11
|
+
/** The fields the visitor can see, by the name a draft stores them under. Hidden fields are never listed. */
|
|
12
|
+
fields: readonly string[];
|
|
13
|
+
/** The form's humanity signals, so draft saves pass the same bot gate as the submit. */
|
|
14
|
+
getSignals?: () => Record<string, string | number>;
|
|
15
|
+
/** Where a stored field lives in the form, when it is nested (`company` → `formFields.company`). Default: the same name. */
|
|
16
|
+
fieldPath?: (name: string) => Path<T>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One level of nesting flattened (`{ formFields: { company } }` → `{ company }`), so nested answers read like top-level ones. */
|
|
20
|
+
function flattenOneLevel(values: Record<string, unknown>): Record<string, unknown> {
|
|
21
|
+
const flat: Record<string, unknown> = {};
|
|
22
|
+
for (const [key, value] of Object.entries(values)) {
|
|
23
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) Object.assign(flat, value);
|
|
24
|
+
else flat[key] = value;
|
|
25
|
+
}
|
|
26
|
+
return flat;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* useRescuedForm — form rescue for ANY react-hook-form form, in one call.
|
|
31
|
+
*
|
|
32
|
+
* Wires what every rescued form needs so no form hand-rolls it: every edit is
|
|
33
|
+
* tracked (`watch`), a resume link or this device's draft fills the empty
|
|
34
|
+
* fields (`setValue`), and the handle's `submitFields()` / `complete()` close
|
|
35
|
+
* the attempt on submit. A form that is not react-hook-form uses
|
|
36
|
+
* `useFormRescue` directly.
|
|
37
|
+
*
|
|
38
|
+
* const rescue = useRescuedForm(form, { rescue: RESCUE_FORMS.contact, fields, getSignals })
|
|
39
|
+
* // submit: body = { ...data, ...getSignals(), ...rescue.submitFields() }
|
|
40
|
+
* // success: rescue.complete()
|
|
41
|
+
*/
|
|
42
|
+
export function useRescuedForm<T extends FieldValues>(
|
|
43
|
+
form: Pick<UseFormReturn<T>, 'watch' | 'getValues' | 'setValue'>,
|
|
44
|
+
{ rescue, fields, getSignals, fieldPath }: UseRescuedFormOptions<T>,
|
|
45
|
+
): FormRescueHandle {
|
|
46
|
+
const { watch, getValues, setValue } = form;
|
|
47
|
+
|
|
48
|
+
const handle = useFormRescue({
|
|
49
|
+
form: rescue,
|
|
50
|
+
fieldNames: fields,
|
|
51
|
+
getSignals,
|
|
52
|
+
onRestore: values => {
|
|
53
|
+
for (const name of fields) {
|
|
54
|
+
const value = values[name];
|
|
55
|
+
if (!value) continue;
|
|
56
|
+
const path = fieldPath ? fieldPath(name) : (name as Path<T>);
|
|
57
|
+
// Only an empty field is filled: never overwrite what the visitor typed.
|
|
58
|
+
if (!getValues(path)) setValue(path, value as PathValue<T, Path<T>>, { shouldDirty: true });
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const subscription = watch((values, { name }) => {
|
|
65
|
+
handle.track(flattenOneLevel(values), name ? (name.split('.').pop() ?? null) : null);
|
|
66
|
+
});
|
|
67
|
+
return () => subscription.unsubscribe();
|
|
68
|
+
}, [watch, handle]);
|
|
69
|
+
|
|
70
|
+
return handle;
|
|
71
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
filledRescueFields,
|
|
5
|
+
isExcludedRescueField,
|
|
6
|
+
isRescueEmail,
|
|
7
|
+
rescueCompletionPct,
|
|
8
|
+
sanitizeRescueFieldName,
|
|
9
|
+
sanitizeRescueValues,
|
|
10
|
+
FORM_RESCUE_MAX_VALUE_CHARS,
|
|
11
|
+
RESCUE_FORMS,
|
|
12
|
+
defineRescueForm,
|
|
13
|
+
getRescueForm,
|
|
14
|
+
listRescueForms,
|
|
15
|
+
} from '../form-rescue';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* These rules run on BOTH sides: the browser before a draft is sent and the
|
|
19
|
+
* host before it is stored. The cases are the ones the privacy contract rests
|
|
20
|
+
* on: nothing outside the allowlist is ever a value, and a credential field is
|
|
21
|
+
* never even reported by name.
|
|
22
|
+
*/
|
|
23
|
+
describe('sanitizeRescueValues', () => {
|
|
24
|
+
it('keeps only allowlisted contact fields, trimmed', () => {
|
|
25
|
+
expect(
|
|
26
|
+
sanitizeRescueValues({
|
|
27
|
+
email: ' alex@northwind-it.com ',
|
|
28
|
+
name: 'Alex',
|
|
29
|
+
company: 'Northwind IT',
|
|
30
|
+
message: 'free text never kept',
|
|
31
|
+
phone: '+15550100',
|
|
32
|
+
password: 'hunter2',
|
|
33
|
+
card_number: '4242424242424242',
|
|
34
|
+
}),
|
|
35
|
+
).toEqual({ email: 'alex@northwind-it.com', name: 'Alex', company: 'Northwind IT' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('caps every value', () => {
|
|
39
|
+
const out = sanitizeRescueValues({ company: 'x'.repeat(500) });
|
|
40
|
+
expect(out.company).toHaveLength(FORM_RESCUE_MAX_VALUE_CHARS);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('drops non-strings and empties', () => {
|
|
44
|
+
expect(sanitizeRescueValues({ email: 42, name: ' ', company: { a: 1 } })).toEqual({});
|
|
45
|
+
expect(sanitizeRescueValues(null)).toEqual({});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('filledRescueFields', () => {
|
|
50
|
+
it('reports visible filled fields by name, never credentials', () => {
|
|
51
|
+
expect(
|
|
52
|
+
filledRescueFields({ email: 'a@b.co', message: 'hi', password: 'x', companySize: '' }, [
|
|
53
|
+
'email',
|
|
54
|
+
'message',
|
|
55
|
+
'password',
|
|
56
|
+
'companySize',
|
|
57
|
+
]),
|
|
58
|
+
).toEqual(['email', 'message']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('counts a nested answer group as filled when any answer is', () => {
|
|
62
|
+
expect(filledRescueFields({ formFields: { a: '', b: 'yes' } }, ['formFields'])).toEqual(['formFields']);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('field and id checks', () => {
|
|
67
|
+
it('excludes credential and payment names', () => {
|
|
68
|
+
for (const name of [
|
|
69
|
+
'password',
|
|
70
|
+
'passcode',
|
|
71
|
+
'cardNumber',
|
|
72
|
+
'cvv',
|
|
73
|
+
'ssn',
|
|
74
|
+
'iban',
|
|
75
|
+
'apiToken',
|
|
76
|
+
'client_secret',
|
|
77
|
+
'otp',
|
|
78
|
+
]) {
|
|
79
|
+
expect(isExcludedRescueField(name)).toBe(true);
|
|
80
|
+
}
|
|
81
|
+
for (const name of ['email', 'company', 'companySize', 'jobtitle']) {
|
|
82
|
+
expect(isExcludedRescueField(name)).toBe(false);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('validates emails the way the contact table does', () => {
|
|
87
|
+
expect(isRescueEmail('alex@northwind-it.com')).toBe(true);
|
|
88
|
+
expect(isRescueEmail('alex@')).toBe(false);
|
|
89
|
+
expect(isRescueEmail(undefined)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('bounds field names', () => {
|
|
93
|
+
expect(sanitizeRescueFieldName('company_size')).toBe('company_size');
|
|
94
|
+
expect(sanitizeRescueFieldName('<script>')).toBeNull();
|
|
95
|
+
expect(sanitizeRescueFieldName('password')).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('computes completion as a bounded percentage', () => {
|
|
99
|
+
expect(rescueCompletionPct(3, 4)).toBe(75);
|
|
100
|
+
expect(rescueCompletionPct(0, 0)).toBe(0);
|
|
101
|
+
expect(rescueCompletionPct(9, 4)).toBe(100);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('rescue form definitions', () => {
|
|
106
|
+
it('knows the lib forms and refuses unknown ids', () => {
|
|
107
|
+
expect(getRescueForm('contact')).toBe(RESCUE_FORMS.contact);
|
|
108
|
+
expect(getRescueForm('meeting_booking')?.label).toBe('Meeting booking');
|
|
109
|
+
expect(getRescueForm('anything_else')).toBeNull();
|
|
110
|
+
expect(getRescueForm(42)).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('lets a host add its own form beside it, with no central list', () => {
|
|
114
|
+
const joinForm = defineRescueForm({ id: 'host_join', label: 'Join form' });
|
|
115
|
+
expect(getRescueForm('host_join')).toBe(joinForm);
|
|
116
|
+
expect(listRescueForms()).toContain(joinForm);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('is idempotent for the same definition and refuses a conflicting one', () => {
|
|
120
|
+
expect(defineRescueForm({ id: 'contact', label: 'Contact form' })).toBe(RESCUE_FORMS.contact);
|
|
121
|
+
expect(() => defineRescueForm({ id: 'contact', label: 'Something else' })).toThrow(/already defined/);
|
|
122
|
+
expect(() => defineRescueForm({ id: 'Bad Id!', label: 'x' })).toThrow(/invalid form id/);
|
|
123
|
+
});
|
|
124
|
+
});
|