@flamingo-stack/openframe-frontend-core 0.0.671 → 0.0.672-snapshot.20260928034232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/chat-protocol/card-marker.d.ts +12 -0
  2. package/dist/chat-protocol/card-marker.d.ts.map +1 -1
  3. package/dist/chat-protocol/index.cjs +17 -0
  4. package/dist/chat-protocol/index.cjs.map +1 -1
  5. package/dist/chat-protocol/index.d.ts +1 -0
  6. package/dist/chat-protocol/index.d.ts.map +1 -1
  7. package/dist/chat-protocol/index.js +15 -1
  8. package/dist/chat-protocol/index.js.map +1 -1
  9. package/dist/{chunk-DAGKBFZN.cjs → chunk-26NSIJMT.cjs} +33 -33
  10. package/dist/{chunk-DAGKBFZN.cjs.map → chunk-26NSIJMT.cjs.map} +1 -1
  11. package/dist/{chunk-FUXUJSPP.cjs → chunk-3UDBK2YG.cjs} +1 -1
  12. package/dist/chunk-3UDBK2YG.cjs.map +1 -0
  13. package/dist/{chunk-XGXHHSDU.cjs → chunk-6QUKYN4K.cjs} +22 -22
  14. package/dist/{chunk-XGXHHSDU.cjs.map → chunk-6QUKYN4K.cjs.map} +1 -1
  15. package/dist/{chunk-SNNE34OP.js → chunk-BF7GZ6BS.js} +5 -5
  16. package/dist/{chunk-SJZPKIW6.cjs → chunk-DRURJCEY.cjs} +12 -12
  17. package/dist/{chunk-SJZPKIW6.cjs.map → chunk-DRURJCEY.cjs.map} +1 -1
  18. package/dist/{chunk-YIDNSRPL.cjs → chunk-G262S3Q5.cjs} +31 -31
  19. package/dist/{chunk-YIDNSRPL.cjs.map → chunk-G262S3Q5.cjs.map} +1 -1
  20. package/dist/{chunk-OG2BNJ2Q.cjs → chunk-HJ532KQR.cjs} +390 -375
  21. package/dist/chunk-HJ532KQR.cjs.map +1 -0
  22. package/dist/{chunk-K3O4D7LW.js → chunk-HRM6242Y.js} +4 -4
  23. package/dist/{chunk-BP542DGQ.cjs → chunk-IFQMIYFO.cjs} +55 -28
  24. package/dist/chunk-IFQMIYFO.cjs.map +1 -0
  25. package/dist/{chunk-IJIIL6WU.js → chunk-IG7M3XKX.js} +6 -6
  26. package/dist/{chunk-PQ7YC5Y6.js → chunk-II6ABVLK.js} +3 -3
  27. package/dist/{chunk-TLLTL6LB.js → chunk-JD4U7QMG.js} +1 -1
  28. package/dist/chunk-JD4U7QMG.js.map +1 -0
  29. package/dist/{chunk-ZRIVCLPR.js → chunk-JD5TKRCU.js} +1 -1
  30. package/dist/chunk-JD5TKRCU.js.map +1 -0
  31. package/dist/{chunk-XH4MRTZM.cjs → chunk-K365MY6G.cjs} +5 -5
  32. package/dist/{chunk-XH4MRTZM.cjs.map → chunk-K365MY6G.cjs.map} +1 -1
  33. package/dist/{chunk-EIAVE6SE.cjs → chunk-KV7W5T6U.cjs} +10 -10
  34. package/dist/{chunk-EIAVE6SE.cjs.map → chunk-KV7W5T6U.cjs.map} +1 -1
  35. package/dist/{chunk-II66XWU5.cjs → chunk-KYF5EONP.cjs} +3 -3
  36. package/dist/{chunk-II66XWU5.cjs.map → chunk-KYF5EONP.cjs.map} +1 -1
  37. package/dist/{chunk-D55IENZJ.cjs → chunk-LCMT2GZK.cjs} +1 -1
  38. package/dist/chunk-LCMT2GZK.cjs.map +1 -0
  39. package/dist/{chunk-STLBB6PF.js → chunk-LMUZZUYC.js} +35 -8
  40. package/dist/chunk-LMUZZUYC.js.map +1 -0
  41. package/dist/{chunk-FAJUQKWQ.cjs → chunk-N6TC6IPV.cjs} +76 -75
  42. package/dist/chunk-N6TC6IPV.cjs.map +1 -0
  43. package/dist/{chunk-RKUCK6MN.js → chunk-OGOMAK7B.js} +2 -2
  44. package/dist/{chunk-G64BNLDO.js → chunk-P4GUIOLP.js} +2 -2
  45. package/dist/{chunk-DUZEFACP.js → chunk-RRU46J3N.js} +3 -3
  46. package/dist/{chunk-DAWYS25M.cjs → chunk-THSIFQGS.cjs} +17 -17
  47. package/dist/{chunk-DAWYS25M.cjs.map → chunk-THSIFQGS.cjs.map} +1 -1
  48. package/dist/{chunk-6C6UUOH4.js → chunk-TTHBFT7Q.js} +8 -7
  49. package/dist/{chunk-6C6UUOH4.js.map → chunk-TTHBFT7Q.js.map} +1 -1
  50. package/dist/{chunk-YNGAJGYB.cjs → chunk-TZP2OXHG.cjs} +346 -6
  51. package/dist/chunk-TZP2OXHG.cjs.map +1 -0
  52. package/dist/{chunk-T3ACDOVD.cjs → chunk-U2H3C4NT.cjs} +7 -7
  53. package/dist/{chunk-T3ACDOVD.cjs.map → chunk-U2H3C4NT.cjs.map} +1 -1
  54. package/dist/{chunk-OYMOG544.js → chunk-UFMXQKVT.js} +5 -5
  55. package/dist/{chunk-NIYIK32F.js → chunk-UMTXGEPU.js} +19 -4
  56. package/dist/{chunk-NIYIK32F.js.map → chunk-UMTXGEPU.js.map} +1 -1
  57. package/dist/{chunk-3PVOF3IB.js → chunk-UTHZ7DLN.js} +343 -3
  58. package/dist/{chunk-3PVOF3IB.js.map → chunk-UTHZ7DLN.js.map} +1 -1
  59. package/dist/{chunk-YJWR7D6O.cjs → chunk-UXT5EK27.cjs} +3 -3
  60. package/dist/{chunk-YJWR7D6O.cjs.map → chunk-UXT5EK27.cjs.map} +1 -1
  61. package/dist/{chunk-UH3BL2OT.js → chunk-VK5737GE.js} +2 -2
  62. package/dist/{chunk-R22IA7MP.js → chunk-VKLM5UKG.js} +4 -4
  63. package/dist/{chunk-FHTNCWIP.js → chunk-X2DR3XNN.js} +5 -5
  64. package/dist/{chunk-GF5TSQ6C.cjs → chunk-YCYOZAB5.cjs} +10 -10
  65. package/dist/{chunk-GF5TSQ6C.cjs.map → chunk-YCYOZAB5.cjs.map} +1 -1
  66. package/dist/{chunk-4PVY4JJN.js → chunk-YWQVL5VJ.js} +3 -3
  67. package/dist/{chunk-PLG6LP7E.cjs → chunk-Z5K4IQZU.cjs} +49 -49
  68. package/dist/{chunk-PLG6LP7E.cjs.map → chunk-Z5K4IQZU.cjs.map} +1 -1
  69. package/dist/components/case-studies/index.cjs +18 -17
  70. package/dist/components/case-studies/index.cjs.map +1 -1
  71. package/dist/components/case-studies/index.js +7 -6
  72. package/dist/components/case-studies/index.js.map +1 -1
  73. package/dist/components/case-studies/share-experience-section.d.ts.map +1 -1
  74. package/dist/components/chat/index.cjs +2 -2
  75. package/dist/components/chat/index.js +1 -1
  76. package/dist/components/contact/contact-form.d.ts +6 -1
  77. package/dist/components/contact/contact-form.d.ts.map +1 -1
  78. package/dist/components/contact/index.cjs +6 -6
  79. package/dist/components/contact/index.js +5 -5
  80. package/dist/components/docs/index.cjs +9 -9
  81. package/dist/components/docs/index.js +8 -8
  82. package/dist/components/embeds/index.cjs +6 -6
  83. package/dist/components/embeds/index.js +5 -5
  84. package/dist/components/faq/index.cjs +6 -6
  85. package/dist/components/faq/index.js +5 -5
  86. package/dist/components/features/index.cjs +5 -5
  87. package/dist/components/features/index.js +4 -4
  88. package/dist/components/features/waitlist-form.d.ts +7 -2
  89. package/dist/components/features/waitlist-form.d.ts.map +1 -1
  90. package/dist/components/help-center-pages/index.cjs +77 -76
  91. package/dist/components/help-center-pages/index.cjs.map +1 -1
  92. package/dist/components/help-center-pages/index.js +17 -16
  93. package/dist/components/help-center-pages/index.js.map +1 -1
  94. package/dist/components/help-center-pages/trust-center-sections.d.ts.map +1 -1
  95. package/dist/components/index.cjs +43 -43
  96. package/dist/components/index.js +15 -15
  97. package/dist/components/meeting-scheduler/booking-form.d.ts +4 -1
  98. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  99. package/dist/components/meeting-scheduler/index.cjs +75 -40
  100. package/dist/components/meeting-scheduler/index.cjs.map +1 -1
  101. package/dist/components/meeting-scheduler/index.d.ts +5 -1
  102. package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
  103. package/dist/components/meeting-scheduler/index.js +46 -11
  104. package/dist/components/meeting-scheduler/index.js.map +1 -1
  105. package/dist/components/navigation/index.cjs +8 -8
  106. package/dist/components/navigation/index.js +7 -7
  107. package/dist/components/onboarding-guides/index.cjs +8 -8
  108. package/dist/components/onboarding-guides/index.js +7 -7
  109. package/dist/components/tickets/help-center-create-form.d.ts.map +1 -1
  110. package/dist/components/tickets/index.cjs +9 -9
  111. package/dist/components/tickets/index.js +8 -8
  112. package/dist/components/ui/index.cjs +5 -5
  113. package/dist/components/ui/index.js +4 -4
  114. package/dist/contexts/endpoints-runtime-context.d.ts +5 -0
  115. package/dist/contexts/endpoints-runtime-context.d.ts.map +1 -1
  116. package/dist/contexts/index.cjs +2 -2
  117. package/dist/contexts/index.js +1 -1
  118. package/dist/hooks/index.cjs +5 -3
  119. package/dist/hooks/index.cjs.map +1 -1
  120. package/dist/hooks/index.d.ts +1 -0
  121. package/dist/hooks/index.d.ts.map +1 -1
  122. package/dist/hooks/index.js +4 -2
  123. package/dist/hooks/use-form-rescue.d.ts +37 -0
  124. package/dist/hooks/use-form-rescue.d.ts.map +1 -0
  125. package/dist/index.cjs +53 -9
  126. package/dist/index.cjs.map +1 -1
  127. package/dist/index.js +52 -8
  128. package/dist/index.js.map +1 -1
  129. package/dist/utils/form-rescue.cjs +123 -0
  130. package/dist/utils/form-rescue.cjs.map +1 -0
  131. package/dist/utils/form-rescue.d.ts +105 -0
  132. package/dist/utils/form-rescue.d.ts.map +1 -0
  133. package/dist/utils/form-rescue.js +101 -0
  134. package/dist/utils/form-rescue.js.map +1 -0
  135. package/dist/utils/index.cjs +119 -0
  136. package/dist/utils/index.cjs.map +1 -1
  137. package/dist/utils/index.d.ts +1 -0
  138. package/dist/utils/index.d.ts.map +1 -1
  139. package/dist/utils/index.js +99 -1
  140. package/dist/utils/index.js.map +1 -1
  141. package/package.json +7 -1
  142. package/src/chat-protocol/__tests__/card-marker.test.ts +27 -1
  143. package/src/chat-protocol/card-marker.ts +24 -0
  144. package/src/chat-protocol/index.ts +3 -0
  145. package/src/components/case-studies/share-experience-section.tsx +1 -0
  146. package/src/components/contact/contact-form.tsx +38 -2
  147. package/src/components/features/waitlist-form.tsx +27 -2
  148. package/src/components/help-center-pages/trust-center-sections.tsx +1 -0
  149. package/src/components/meeting-scheduler/booking-form.tsx +14 -0
  150. package/src/components/meeting-scheduler/index.tsx +38 -3
  151. package/src/components/tickets/help-center-create-form.tsx +2 -0
  152. package/src/contexts/endpoints-runtime-context.tsx +5 -0
  153. package/src/hooks/__tests__/use-form-rescue.test.tsx +189 -0
  154. package/src/hooks/index.ts +3 -0
  155. package/src/hooks/use-form-rescue.ts +315 -0
  156. package/src/utils/__tests__/form-rescue.test.ts +106 -0
  157. package/src/utils/form-rescue.ts +198 -0
  158. package/src/utils/index.ts +5 -0
  159. package/dist/chunk-BP542DGQ.cjs.map +0 -1
  160. package/dist/chunk-D55IENZJ.cjs.map +0 -1
  161. package/dist/chunk-FAJUQKWQ.cjs.map +0 -1
  162. package/dist/chunk-FUXUJSPP.cjs.map +0 -1
  163. package/dist/chunk-OG2BNJ2Q.cjs.map +0 -1
  164. package/dist/chunk-STLBB6PF.js.map +0 -1
  165. package/dist/chunk-TLLTL6LB.js.map +0 -1
  166. package/dist/chunk-YNGAJGYB.cjs.map +0 -1
  167. package/dist/chunk-ZRIVCLPR.js.map +0 -1
  168. /package/dist/{chunk-SNNE34OP.js.map → chunk-BF7GZ6BS.js.map} +0 -0
  169. /package/dist/{chunk-K3O4D7LW.js.map → chunk-HRM6242Y.js.map} +0 -0
  170. /package/dist/{chunk-IJIIL6WU.js.map → chunk-IG7M3XKX.js.map} +0 -0
  171. /package/dist/{chunk-PQ7YC5Y6.js.map → chunk-II6ABVLK.js.map} +0 -0
  172. /package/dist/{chunk-RKUCK6MN.js.map → chunk-OGOMAK7B.js.map} +0 -0
  173. /package/dist/{chunk-G64BNLDO.js.map → chunk-P4GUIOLP.js.map} +0 -0
  174. /package/dist/{chunk-DUZEFACP.js.map → chunk-RRU46J3N.js.map} +0 -0
  175. /package/dist/{chunk-OYMOG544.js.map → chunk-UFMXQKVT.js.map} +0 -0
  176. /package/dist/{chunk-UH3BL2OT.js.map → chunk-VK5737GE.js.map} +0 -0
  177. /package/dist/{chunk-R22IA7MP.js.map → chunk-VKLM5UKG.js.map} +0 -0
  178. /package/dist/{chunk-FHTNCWIP.js.map → chunk-X2DR3XNN.js.map} +0 -0
  179. /package/dist/{chunk-4PVY4JJN.js.map → chunk-YWQVL5VJ.js.map} +0 -0
@@ -0,0 +1,315 @@
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 FormRescueFormId,
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; `null` turns rescue off (e.g. a signed-in ticket form). */
38
+ formId: FormRescueFormId | 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
+ function readUtm(): FormDraftSaveRequest['utm'] {
97
+ try {
98
+ const params = new URLSearchParams(window.location.search);
99
+ const utm: NonNullable<FormDraftSaveRequest['utm']> = {};
100
+ for (const key of UTM_KEYS) {
101
+ const value = params.get(`utm_${key}`);
102
+ if (value) utm[key] = value.slice(0, 200);
103
+ }
104
+ return Object.keys(utm).length ? utm : undefined;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * useFormRescue — saves a public form's progress so a visitor who leaves
112
+ * before submitting can be followed up with, and restores it when they return.
113
+ *
114
+ * Every public lead form calls it the same way (next to `useHumanitySignals`):
115
+ *
116
+ * const rescue = useFormRescue({ formId: 'contact', fieldNames, onRestore, getSignals })
117
+ * // on change: rescue.track(values, changedField)
118
+ * // submit: body = { ...data, ...getSignals(), ...rescue.submitFields() }
119
+ * // success: rescue.complete()
120
+ *
121
+ * Saves go to the host's `EndpointsRuntime.formDraftsUrl` (debounced, plus one
122
+ * final keepalive save when the tab is hidden). A host that does not provide
123
+ * the url gets the local draft only. Nothing here can block or delay a submit:
124
+ * every network and storage call is fire-and-forget and swallows its errors.
125
+ */
126
+ export function useFormRescue({ formId, fieldNames, onRestore, getSignals }: UseFormRescueOptions): FormRescueHandle {
127
+ const draftsUrl = useEndpointsRuntime()?.formDraftsUrl;
128
+
129
+ const attemptIdRef = useRef<string | null>(null);
130
+ const resumeTokenRef = useRef<string | null>(null);
131
+ const latestRef = useRef<FormDraftProgress | null>(null);
132
+ const dirtyRef = useRef(false);
133
+ const startedRef = useRef(false);
134
+ const leadCapturedRef = useRef(false);
135
+ const completedRef = useRef(false);
136
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
137
+
138
+ // Callers pass inline arrays and callbacks; read them through refs so the
139
+ // returned handle stays stable and effects run once. Written in an effect
140
+ // (declared first, so it runs before the restore below), never during render.
141
+ const fieldNamesRef = useRef(fieldNames);
142
+ const onRestoreRef = useRef(onRestore);
143
+ const getSignalsRef = useRef(getSignals);
144
+ useEffect(() => {
145
+ fieldNamesRef.current = fieldNames;
146
+ onRestoreRef.current = onRestore;
147
+ getSignalsRef.current = getSignals;
148
+ });
149
+
150
+ const attemptId = useCallback((): string => {
151
+ if (!attemptIdRef.current) attemptIdRef.current = newAttemptId();
152
+ return attemptIdRef.current;
153
+ }, []);
154
+
155
+ const send = useCallback(
156
+ (keepalive: boolean) => {
157
+ if (!formId || !draftsUrl || !dirtyRef.current || completedRef.current || !latestRef.current) return;
158
+ dirtyRef.current = false;
159
+ const body: FormDraftSaveRequest = {
160
+ attempt_id: attemptId(),
161
+ form_id: formId,
162
+ ...latestRef.current,
163
+ source_path: typeof window !== 'undefined' ? window.location.pathname : '/',
164
+ utm: readUtm(),
165
+ };
166
+ try {
167
+ void contentFetch(draftsUrl, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify({ ...body, ...(getSignalsRef.current?.() ?? {}) }),
171
+ keepalive,
172
+ }).catch(() => undefined);
173
+ } catch {
174
+ // A failed save is retried by the next change.
175
+ }
176
+ captureFormRescueEvent(FORM_RESCUE_EVENTS.progress, {
177
+ form_id: formId,
178
+ attempt_id: attemptId(),
179
+ fields_filled: body.fields_filled,
180
+ last_field: body.last_field,
181
+ completion_pct: body.completion_pct,
182
+ });
183
+ },
184
+ [attemptId, draftsUrl, formId],
185
+ );
186
+
187
+ // Restore once on mount: a resume link wins over this device's local draft.
188
+ useEffect(() => {
189
+ if (!formId || typeof window === 'undefined') return undefined;
190
+ let cancelled = false;
191
+ const token = new URLSearchParams(window.location.search).get(FORM_RESCUE_RESUME_PARAM);
192
+
193
+ const restoreLocal = () => {
194
+ const local = readLocalDraft(formId);
195
+ if (!local) return;
196
+ attemptIdRef.current = local.attemptId;
197
+ if (Object.keys(local.values).length) onRestoreRef.current?.(local.values);
198
+ };
199
+
200
+ if (token && draftsUrl && /^[A-Za-z0-9_-]{16,128}$/.test(token)) {
201
+ contentFetch(`${draftsUrl}/resume/${encodeURIComponent(token)}`)
202
+ .then(async res => (res.ok ? ((await res.json()) as FormDraftResumeResponse) : null))
203
+ .then(data => {
204
+ if (cancelled) return;
205
+ if (!data || data.form_id !== formId) {
206
+ restoreLocal();
207
+ return;
208
+ }
209
+ resumeTokenRef.current = token;
210
+ onRestoreRef.current?.(sanitizeRescueValues(data.values));
211
+ captureFormRescueEvent(FORM_RESCUE_EVENTS.resumed, { form_id: formId, attempt_id: attemptId() });
212
+ })
213
+ .catch(() => {
214
+ if (!cancelled) restoreLocal();
215
+ });
216
+ } else {
217
+ restoreLocal();
218
+ }
219
+ return () => {
220
+ cancelled = true;
221
+ };
222
+ }, [attemptId, draftsUrl, formId]);
223
+
224
+ // Final save when the visitor hides or leaves the tab.
225
+ useEffect(() => {
226
+ if (!formId || typeof document === 'undefined') return undefined;
227
+ const onHide = () => {
228
+ if (completedRef.current || !startedRef.current) return;
229
+ if (timerRef.current) {
230
+ clearTimeout(timerRef.current);
231
+ timerRef.current = null;
232
+ }
233
+ send(true);
234
+ captureFormRescueEvent(FORM_RESCUE_EVENTS.abandoned, {
235
+ form_id: formId,
236
+ attempt_id: attemptId(),
237
+ last_field: latestRef.current?.last_field ?? null,
238
+ completion_pct: latestRef.current?.completion_pct ?? 0,
239
+ });
240
+ };
241
+ const onVisibility = () => {
242
+ if (document.visibilityState === 'hidden') onHide();
243
+ };
244
+ document.addEventListener('visibilitychange', onVisibility);
245
+ window.addEventListener('pagehide', onHide);
246
+ return () => {
247
+ document.removeEventListener('visibilitychange', onVisibility);
248
+ window.removeEventListener('pagehide', onHide);
249
+ if (timerRef.current) clearTimeout(timerRef.current);
250
+ };
251
+ }, [attemptId, formId, send]);
252
+
253
+ const track = useCallback(
254
+ (values: Record<string, unknown>, lastField?: string | null) => {
255
+ if (!formId || completedRef.current) return;
256
+ const names = fieldNamesRef.current;
257
+ const visible: Record<string, unknown> = {};
258
+ for (const name of names) visible[name] = values[name];
259
+ const filled = filledRescueFields(visible, names);
260
+ if (!filled.length) return;
261
+
262
+ if (!startedRef.current) {
263
+ startedRef.current = true;
264
+ captureFormRescueEvent(FORM_RESCUE_EVENTS.started, { form_id: formId, attempt_id: attemptId() });
265
+ }
266
+ const clean = sanitizeRescueValues(visible);
267
+ if (!leadCapturedRef.current && isRescueEmail(clean.email)) {
268
+ leadCapturedRef.current = true;
269
+ captureFormRescueEvent(FORM_RESCUE_EVENTS.leadCaptured, { form_id: formId, attempt_id: attemptId() });
270
+ }
271
+ latestRef.current = {
272
+ values: clean,
273
+ fields_filled: filled,
274
+ last_field: sanitizeRescueFieldName(lastField),
275
+ completion_pct: rescueCompletionPct(filled.length, names.length),
276
+ };
277
+ dirtyRef.current = true;
278
+ writeLocalDraft(formId, { attemptId: attemptId(), values: clean, savedAt: Date.now() });
279
+
280
+ if (timerRef.current) clearTimeout(timerRef.current);
281
+ timerRef.current = setTimeout(() => {
282
+ timerRef.current = null;
283
+ send(false);
284
+ }, FORM_RESCUE_DEBOUNCE_MS);
285
+ },
286
+ [attemptId, formId, send],
287
+ );
288
+
289
+ const submitFields = useCallback((): Record<string, string> => {
290
+ if (!formId) return {};
291
+ return {
292
+ [FORM_RESCUE_ATTEMPT_FIELD]: attemptId(),
293
+ ...(resumeTokenRef.current ? { [FORM_RESCUE_RESUME_FIELD]: resumeTokenRef.current } : {}),
294
+ };
295
+ }, [attemptId, formId]);
296
+
297
+ const complete = useCallback(() => {
298
+ if (!formId) return;
299
+ if (timerRef.current) {
300
+ clearTimeout(timerRef.current);
301
+ timerRef.current = null;
302
+ }
303
+ captureFormRescueEvent(FORM_RESCUE_EVENTS.submitted, { form_id: formId, attempt_id: attemptId() });
304
+ clearLocalDraft(formId);
305
+ // A fresh attempt for a second submission from the same page.
306
+ attemptIdRef.current = null;
307
+ resumeTokenRef.current = null;
308
+ latestRef.current = null;
309
+ dirtyRef.current = false;
310
+ startedRef.current = false;
311
+ leadCapturedRef.current = false;
312
+ }, [attemptId, formId]);
313
+
314
+ return useMemo(() => ({ track, submitFields, complete }), [track, submitFields, complete]);
315
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ filledRescueFields,
5
+ isExcludedRescueField,
6
+ isFormRescueFormId,
7
+ isRescueEmail,
8
+ rescueCompletionPct,
9
+ sanitizeRescueFieldName,
10
+ sanitizeRescueValues,
11
+ FORM_RESCUE_MAX_VALUE_CHARS,
12
+ } from '../form-rescue';
13
+
14
+ /**
15
+ * These rules run on BOTH sides: the browser before a draft is sent and the
16
+ * host before it is stored. The cases are the ones the privacy contract rests
17
+ * on: nothing outside the allowlist is ever a value, and a credential field is
18
+ * never even reported by name.
19
+ */
20
+ describe('sanitizeRescueValues', () => {
21
+ it('keeps only allowlisted contact fields, trimmed', () => {
22
+ expect(
23
+ sanitizeRescueValues({
24
+ email: ' alex@northwind-it.com ',
25
+ name: 'Alex',
26
+ company: 'Northwind IT',
27
+ message: 'free text never kept',
28
+ phone: '+15550100',
29
+ password: 'hunter2',
30
+ card_number: '4242424242424242',
31
+ }),
32
+ ).toEqual({ email: 'alex@northwind-it.com', name: 'Alex', company: 'Northwind IT' });
33
+ });
34
+
35
+ it('caps every value', () => {
36
+ const out = sanitizeRescueValues({ company: 'x'.repeat(500) });
37
+ expect(out.company).toHaveLength(FORM_RESCUE_MAX_VALUE_CHARS);
38
+ });
39
+
40
+ it('drops non-strings and empties', () => {
41
+ expect(sanitizeRescueValues({ email: 42, name: ' ', company: { a: 1 } })).toEqual({});
42
+ expect(sanitizeRescueValues(null)).toEqual({});
43
+ });
44
+ });
45
+
46
+ describe('filledRescueFields', () => {
47
+ it('reports visible filled fields by name, never credentials', () => {
48
+ expect(
49
+ filledRescueFields({ email: 'a@b.co', message: 'hi', password: 'x', companySize: '' }, [
50
+ 'email',
51
+ 'message',
52
+ 'password',
53
+ 'companySize',
54
+ ]),
55
+ ).toEqual(['email', 'message']);
56
+ });
57
+
58
+ it('counts a nested answer group as filled when any answer is', () => {
59
+ expect(filledRescueFields({ formFields: { a: '', b: 'yes' } }, ['formFields'])).toEqual(['formFields']);
60
+ });
61
+ });
62
+
63
+ describe('field and id checks', () => {
64
+ it('excludes credential and payment names', () => {
65
+ for (const name of [
66
+ 'password',
67
+ 'passcode',
68
+ 'cardNumber',
69
+ 'cvv',
70
+ 'ssn',
71
+ 'iban',
72
+ 'apiToken',
73
+ 'client_secret',
74
+ 'otp',
75
+ ]) {
76
+ expect(isExcludedRescueField(name)).toBe(true);
77
+ }
78
+ for (const name of ['email', 'company', 'companySize', 'jobtitle']) {
79
+ expect(isExcludedRescueField(name)).toBe(false);
80
+ }
81
+ });
82
+
83
+ it('accepts only known form ids', () => {
84
+ expect(isFormRescueFormId('contact')).toBe(true);
85
+ expect(isFormRescueFormId('meeting_booking')).toBe(true);
86
+ expect(isFormRescueFormId('anything_else')).toBe(false);
87
+ });
88
+
89
+ it('validates emails the way the contact table does', () => {
90
+ expect(isRescueEmail('alex@northwind-it.com')).toBe(true);
91
+ expect(isRescueEmail('alex@')).toBe(false);
92
+ expect(isRescueEmail(undefined)).toBe(false);
93
+ });
94
+
95
+ it('bounds field names', () => {
96
+ expect(sanitizeRescueFieldName('company_size')).toBe('company_size');
97
+ expect(sanitizeRescueFieldName('<script>')).toBeNull();
98
+ expect(sanitizeRescueFieldName('password')).toBeNull();
99
+ });
100
+
101
+ it('computes completion as a bounded percentage', () => {
102
+ expect(rescueCompletionPct(3, 4)).toBe(75);
103
+ expect(rescueCompletionPct(0, 0)).toBe(0);
104
+ expect(rescueCompletionPct(9, 4)).toBe(100);
105
+ });
106
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Form rescue — the shared rules for saving a half-filled public form so the
3
+ * team can follow up with a visitor who left before submitting.
4
+ *
5
+ * Pure and server-safe: the browser hook (`hooks/use-form-rescue`) and the
6
+ * host's draft endpoint import the SAME allowlist, exclusions, caps and
7
+ * completion math, so the server re-applies exactly the filter the client ran
8
+ * and a rename here reaches both sides at once. Also exported through the
9
+ * granular subpath `./utils/form-rescue` for server-only consumers.
10
+ *
11
+ * What a draft may carry:
12
+ * - values of ALLOWLISTED contact fields only (`FORM_RESCUE_VALUE_FIELDS`),
13
+ * trimmed and capped at `FORM_RESCUE_MAX_VALUE_CHARS`;
14
+ * - for every other field, only whether it is filled (never its value);
15
+ * - nothing whose name looks like a credential or payment field
16
+ * (`isExcludedRescueField`), whatever the allowlist says.
17
+ *
18
+ * Field values go to the host only, never to analytics: the PostHog events
19
+ * (`captureFormRescueEvent`) carry ids, field names and percentages.
20
+ */
21
+
22
+ /** Every public form that can be rescued. The host validates `form_id` against this list. */
23
+ export const FORM_RESCUE_FORM_IDS = [
24
+ 'contact',
25
+ 'case_study_pitch',
26
+ 'data_room_request',
27
+ 'trust_center_request',
28
+ 'tmcg_join',
29
+ 'meeting_booking',
30
+ 'waitlist',
31
+ ] as const;
32
+
33
+ export type FormRescueFormId = (typeof FORM_RESCUE_FORM_IDS)[number];
34
+
35
+ export function isFormRescueFormId(value: unknown): value is FormRescueFormId {
36
+ return typeof value === 'string' && (FORM_RESCUE_FORM_IDS as readonly string[]).includes(value);
37
+ }
38
+
39
+ /**
40
+ * Fields whose VALUES a draft keeps: who the visitor is and where they work.
41
+ * Covers both naming schemes in use (the contact form's `name` / `companySize`,
42
+ * the meeting booking's HubSpot `firstName` / `company` / `jobtitle`).
43
+ * Free text (`message`) and phone numbers are reported as filled only.
44
+ */
45
+ export const FORM_RESCUE_VALUE_FIELDS = [
46
+ 'email',
47
+ 'name',
48
+ 'firstName',
49
+ 'lastName',
50
+ 'company',
51
+ 'jobtitle',
52
+ 'companySize',
53
+ 'linkedin_url',
54
+ ] as const;
55
+
56
+ /** Never send or store these, whatever the allowlist says. */
57
+ const EXCLUDED_FIELD_PATTERN =
58
+ /pass(word|code)?|card|cvv|cvc|ssn|social.?security|iban|bank|routing|token|secret|otp|pin$/i;
59
+
60
+ export function isExcludedRescueField(name: string): boolean {
61
+ return EXCLUDED_FIELD_PATTERN.test(name);
62
+ }
63
+
64
+ /** Longest value a draft keeps per field. */
65
+ export const FORM_RESCUE_MAX_VALUE_CHARS = 200;
66
+ /** Save this long after the visitor stops typing. */
67
+ export const FORM_RESCUE_DEBOUNCE_MS = 1500;
68
+ /** Most fields a draft reports (filled names), a bound on the payload. */
69
+ export const FORM_RESCUE_MAX_FIELDS = 40;
70
+
71
+ /** Body keys a form's SUBMIT carries so the host can close the matching draft. */
72
+ export const FORM_RESCUE_ATTEMPT_FIELD = 'form_attempt_id';
73
+ export const FORM_RESCUE_RESUME_FIELD = 'form_resume_token';
74
+ /** Every rescue key that rides in a submit body. Hosts forwarding a payload upstream strip by THIS array. */
75
+ export const FORM_RESCUE_SUBMIT_KEYS = [FORM_RESCUE_ATTEMPT_FIELD, FORM_RESCUE_RESUME_FIELD] as const;
76
+
77
+ /** Query parameter a resume link carries. */
78
+ export const FORM_RESCUE_RESUME_PARAM = 'resume';
79
+
80
+ /** Draft lifecycle. `submitted` is final; `rescued` can still become `submitted`. */
81
+ export const FORM_DRAFT_STATUSES = ['started', 'rescuable', 'rescued', 'submitted'] as const;
82
+ export type FormDraftStatus = (typeof FORM_DRAFT_STATUSES)[number];
83
+
84
+ /** Why a due draft was settled WITHOUT alerting the team (stored on the draft). */
85
+ export const FORM_RESCUE_SKIP_REASONS = {
86
+ finishedElsewhere: 'finished_elsewhere',
87
+ alreadyRescued: 'already_rescued',
88
+ internal: 'internal_email',
89
+ } as const;
90
+ export type FormRescueSkipReason = (typeof FORM_RESCUE_SKIP_REASONS)[keyof typeof FORM_RESCUE_SKIP_REASONS];
91
+
92
+ /** Same shape the host's `contact_submissions.email` check accepts. */
93
+ const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
94
+
95
+ export function isRescueEmail(value: unknown): value is string {
96
+ return typeof value === 'string' && value.length <= FORM_RESCUE_MAX_VALUE_CHARS && EMAIL_PATTERN.test(value.trim());
97
+ }
98
+
99
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
100
+
101
+ export function isFormAttemptId(value: unknown): value is string {
102
+ return typeof value === 'string' && UUID_PATTERN.test(value);
103
+ }
104
+
105
+ function isFilled(value: unknown): boolean {
106
+ if (value == null) return false;
107
+ if (typeof value === 'string') return value.trim().length > 0;
108
+ if (typeof value === 'boolean') return value;
109
+ if (Array.isArray(value)) return value.length > 0;
110
+ if (typeof value === 'object') return Object.values(value as Record<string, unknown>).some(isFilled);
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Keep only allowlisted, non-excluded string values, trimmed and capped.
116
+ * The ONE filter, run by the browser before sending and by the host before storing.
117
+ */
118
+ export function sanitizeRescueValues(values: Record<string, unknown> | null | undefined): Record<string, string> {
119
+ const out: Record<string, string> = {};
120
+ if (!values || typeof values !== 'object') return out;
121
+ for (const key of FORM_RESCUE_VALUE_FIELDS) {
122
+ if (isExcludedRescueField(key)) continue;
123
+ const raw = values[key];
124
+ if (typeof raw !== 'string') continue;
125
+ const trimmed = raw.trim().slice(0, FORM_RESCUE_MAX_VALUE_CHARS);
126
+ if (trimmed) out[key] = trimmed;
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** Names of the given fields that hold something, credentials never included. */
132
+ export function filledRescueFields(values: Record<string, unknown>, fieldNames: readonly string[]): string[] {
133
+ return fieldNames
134
+ .filter(name => !isExcludedRescueField(name) && isFilled(values[name]))
135
+ .slice(0, FORM_RESCUE_MAX_FIELDS);
136
+ }
137
+
138
+ export function rescueCompletionPct(filledCount: number, totalCount: number): number {
139
+ if (totalCount <= 0) return 0;
140
+ return Math.max(0, Math.min(100, Math.round((filledCount / totalCount) * 100)));
141
+ }
142
+
143
+ /** A field NAME the host stores (`last_field`, `fields_filled`); bounded, printable. */
144
+ export function sanitizeRescueFieldName(value: unknown): string | null {
145
+ if (typeof value !== 'string') return null;
146
+ const trimmed = value.trim();
147
+ return /^[A-Za-z0-9_.-]{1,64}$/.test(trimmed) && !isExcludedRescueField(trimmed) ? trimmed : null;
148
+ }
149
+
150
+ /** A form's progress at one moment: allowlisted values, filled field names, position. */
151
+ export interface FormDraftProgress {
152
+ values: Record<string, string>;
153
+ fields_filled: string[];
154
+ last_field: string | null;
155
+ completion_pct: number;
156
+ }
157
+
158
+ /** What the browser sends on every save (`POST <formDraftsUrl>`), beside the humanity signals. */
159
+ export interface FormDraftSaveRequest extends FormDraftProgress {
160
+ /** The attempt's idempotency key: every save of one attempt updates one draft. */
161
+ attempt_id: string;
162
+ form_id: FormRescueFormId;
163
+ source_path: string;
164
+ utm?: Partial<Record<'source' | 'medium' | 'campaign' | 'content' | 'term', string>>;
165
+ }
166
+
167
+ /** What the host answers a resume link with: the allowlisted values, nothing internal. */
168
+ export interface FormDraftResumeResponse {
169
+ form_id: FormRescueFormId;
170
+ values: Record<string, string>;
171
+ }
172
+
173
+ /** The PostHog events this feature emits. Properties are ids, names and numbers, never values. */
174
+ export const FORM_RESCUE_EVENTS = {
175
+ started: 'form_started',
176
+ progress: 'form_progress',
177
+ leadCaptured: 'form_lead_captured',
178
+ abandoned: 'form_abandoned',
179
+ submitted: 'form_submitted',
180
+ rescued: 'form_rescued',
181
+ resumed: 'form_resumed',
182
+ } as const;
183
+
184
+ type AnalyticsValue = string | number | boolean | string[] | null;
185
+
186
+ /**
187
+ * Send one form event to PostHog when the page loaded it (GTM exposes
188
+ * `window.posthog`); a no-op otherwise. Never throws.
189
+ */
190
+ export function captureFormRescueEvent(event: string, properties: Record<string, AnalyticsValue>): void {
191
+ try {
192
+ if (typeof window === 'undefined') return;
193
+ const ph = (window as unknown as { posthog?: { capture?: (e: string, p: object) => void } }).posthog;
194
+ if (typeof ph?.capture === 'function') ph.capture(event, properties);
195
+ } catch {
196
+ // Analytics never breaks a form.
197
+ }
198
+ }
@@ -387,6 +387,11 @@ export {
387
387
  // `./utils/humanity-signals` for server-only consumers.
388
388
  export * from './humanity-signals';
389
389
 
390
+ // Form rescue rules (allowlist, exclusions, caps, lifecycle) — pure + server-safe
391
+ // so the host's draft endpoint re-applies the same filter the browser hook runs.
392
+ // Also exported via the granular subpath `./utils/form-rescue`.
393
+ export * from './form-rescue';
394
+
390
395
  // Doc-source viewer utilities (path parsing, tree building, section extraction,
391
396
  // embed-URL conversion) — single home for all doc-viewer pure helpers across
392
397
  // hub + lib consumers (knowledge-base, data-room, and future sources).