@emdash-cms/plugin-forms 0.0.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/package.json +40 -0
- package/src/admin.tsx +1288 -0
- package/src/astro/Form.astro +26 -0
- package/src/astro/FormEmbed.astro +301 -0
- package/src/astro/index.ts +11 -0
- package/src/client/index.ts +536 -0
- package/src/format.ts +160 -0
- package/src/handlers/cron.ts +151 -0
- package/src/handlers/forms.ts +269 -0
- package/src/handlers/submissions.ts +191 -0
- package/src/handlers/submit.ts +297 -0
- package/src/index.ts +230 -0
- package/src/schemas.ts +215 -0
- package/src/storage.ts +41 -0
- package/src/styles/forms.css +200 -0
- package/src/turnstile.ts +51 -0
- package/src/types.ts +164 -0
- package/src/validation.ts +205 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side form enhancement.
|
|
3
|
+
*
|
|
4
|
+
* Following the same progressive enhancement pattern as Astro's <ClientRouter />,
|
|
5
|
+
* this uses event delegation on `document` — a single set of listeners handles
|
|
6
|
+
* all forms on the page, including forms added after initial load.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - AJAX submission (no page reload)
|
|
10
|
+
* - Client-side validation with inline errors
|
|
11
|
+
* - Multi-page navigation with history integration
|
|
12
|
+
* - Conditional field visibility
|
|
13
|
+
* - Session persistence (survives page refreshes)
|
|
14
|
+
* - Turnstile widget injection
|
|
15
|
+
* - File upload with FormData
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const STORAGE_PREFIX = "ec-form:";
|
|
19
|
+
const DEBOUNCE_MS = 500;
|
|
20
|
+
|
|
21
|
+
let saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
22
|
+
let listenersRegistered = false;
|
|
23
|
+
|
|
24
|
+
// ─── Initialization ──────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export function initForms() {
|
|
27
|
+
const init = () => {
|
|
28
|
+
document.querySelectorAll<HTMLFormElement>("[data-ec-form]").forEach((form) => {
|
|
29
|
+
if (form.dataset.ecInitialized) return;
|
|
30
|
+
form.dataset.ecInitialized = "1";
|
|
31
|
+
restoreState(form);
|
|
32
|
+
initMultiPage(form);
|
|
33
|
+
initConditions(form);
|
|
34
|
+
initTurnstile(form);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Guard against duplicate listener registration
|
|
39
|
+
if (!listenersRegistered) {
|
|
40
|
+
listenersRegistered = true;
|
|
41
|
+
|
|
42
|
+
// Event delegation — handles all forms, current and future
|
|
43
|
+
document.addEventListener("submit", handleSubmit);
|
|
44
|
+
document.addEventListener("click", handleClick);
|
|
45
|
+
document.addEventListener("input", handleInput);
|
|
46
|
+
document.addEventListener("change", handleChange);
|
|
47
|
+
window.addEventListener("popstate", handlePopState);
|
|
48
|
+
|
|
49
|
+
// Astro ClientRouter fires astro:page-load on every navigation
|
|
50
|
+
document.addEventListener("astro:page-load", init);
|
|
51
|
+
|
|
52
|
+
// Clean up pending save timers before view transitions swap the DOM
|
|
53
|
+
document.addEventListener("astro:before-swap", () => {
|
|
54
|
+
for (const timer of saveTimers.values()) {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
saveTimers.clear();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fallback for sites without ClientRouter
|
|
62
|
+
if (document.readyState === "loading") {
|
|
63
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
64
|
+
} else {
|
|
65
|
+
init();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Submit Handler ──────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
async function handleSubmit(e: Event) {
|
|
72
|
+
const form = (e.target as HTMLElement).closest<HTMLFormElement>("[data-ec-form]");
|
|
73
|
+
if (!form) return;
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
|
|
76
|
+
// Validate current (or last) page
|
|
77
|
+
if (!validateVisibleFields(form)) return;
|
|
78
|
+
|
|
79
|
+
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
|
|
80
|
+
if (submitBtn) {
|
|
81
|
+
submitBtn.disabled = true;
|
|
82
|
+
submitBtn.textContent = "Submitting...";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
clearStatus(form);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const hasFiles = form.querySelector<HTMLInputElement>('input[type="file"]');
|
|
89
|
+
let body: BodyInit;
|
|
90
|
+
const headers: Record<string, string> = {};
|
|
91
|
+
|
|
92
|
+
if (hasFiles) {
|
|
93
|
+
body = new FormData(form);
|
|
94
|
+
} else {
|
|
95
|
+
headers["Content-Type"] = "application/json";
|
|
96
|
+
const formData = new FormData(form);
|
|
97
|
+
let formId = "";
|
|
98
|
+
const data: Record<string, unknown> = {};
|
|
99
|
+
// Track keys we've seen to detect multi-value fields (checkbox-group)
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
for (const [key, val] of formData) {
|
|
102
|
+
if (typeof val !== "string") continue;
|
|
103
|
+
if (key === "formId") {
|
|
104
|
+
formId = val;
|
|
105
|
+
} else if (key === "_hp" || key === "cf-turnstile-response") {
|
|
106
|
+
// Include spam fields at top level for server-side checks
|
|
107
|
+
data[key] = val;
|
|
108
|
+
} else if (seen.has(key)) {
|
|
109
|
+
// Multi-value field (checkbox-group) — collect into array
|
|
110
|
+
const existing = data[key];
|
|
111
|
+
if (Array.isArray(existing)) {
|
|
112
|
+
existing.push(val);
|
|
113
|
+
} else {
|
|
114
|
+
data[key] = [existing, val];
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
seen.add(key);
|
|
118
|
+
data[key] = val;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
body = JSON.stringify({ formId, data });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const res = await fetch(form.action, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers,
|
|
127
|
+
body,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const result = (await res.json()) as {
|
|
131
|
+
success?: boolean;
|
|
132
|
+
message?: string;
|
|
133
|
+
redirect?: string;
|
|
134
|
+
errors?: Array<{ field: string; message: string }>;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (result.success) {
|
|
138
|
+
clearSavedState(form);
|
|
139
|
+
if (result.redirect) {
|
|
140
|
+
window.location.href = result.redirect;
|
|
141
|
+
} else {
|
|
142
|
+
showStatus(form, result.message || "Submitted successfully.", "success");
|
|
143
|
+
form.reset();
|
|
144
|
+
}
|
|
145
|
+
} else if (result.errors) {
|
|
146
|
+
showErrors(form, result.errors);
|
|
147
|
+
} else {
|
|
148
|
+
showStatus(form, "Something went wrong. Please try again.", "error");
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
showStatus(form, "Network error. Please try again.", "error");
|
|
152
|
+
} finally {
|
|
153
|
+
if (submitBtn) {
|
|
154
|
+
submitBtn.disabled = false;
|
|
155
|
+
submitBtn.textContent = form.dataset.submitLabel || "Submit";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Click Handler (Prev/Next) ───────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function handleClick(e: Event) {
|
|
163
|
+
const target = e.target as HTMLElement;
|
|
164
|
+
|
|
165
|
+
const nextBtn = target.closest("[data-ec-next]");
|
|
166
|
+
if (nextBtn) {
|
|
167
|
+
const form = nextBtn.closest<HTMLFormElement>("[data-ec-form]");
|
|
168
|
+
if (form) {
|
|
169
|
+
const current = getCurrentPage(form);
|
|
170
|
+
if (validatePage(form, current)) {
|
|
171
|
+
showPage(form, current + 1);
|
|
172
|
+
saveState(form);
|
|
173
|
+
history.pushState({ ecFormPage: current + 1, ecFormId: form.dataset.formId }, "");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const prevBtn = target.closest("[data-ec-prev]");
|
|
180
|
+
if (prevBtn) {
|
|
181
|
+
const form = prevBtn.closest<HTMLFormElement>("[data-ec-form]");
|
|
182
|
+
if (form) {
|
|
183
|
+
const current = getCurrentPage(form);
|
|
184
|
+
if (current > 0) {
|
|
185
|
+
showPage(form, current - 1);
|
|
186
|
+
saveState(form);
|
|
187
|
+
history.pushState({ ecFormPage: current - 1, ecFormId: form.dataset.formId }, "");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Input/Change Handlers ───────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
function handleInput(e: Event) {
|
|
196
|
+
const target = e.target as HTMLElement;
|
|
197
|
+
const form = target.closest<HTMLFormElement>("[data-ec-form]");
|
|
198
|
+
if (!form) return;
|
|
199
|
+
|
|
200
|
+
// Clear field error on input
|
|
201
|
+
const name = (target as HTMLInputElement).name;
|
|
202
|
+
if (name) {
|
|
203
|
+
const errorEl = form.querySelector(`[data-error-for="${name}"]`);
|
|
204
|
+
if (errorEl) errorEl.textContent = "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Debounced save
|
|
208
|
+
debouncedSave(form);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function handleChange(e: Event) {
|
|
212
|
+
const target = e.target as HTMLElement;
|
|
213
|
+
const form = target.closest<HTMLFormElement>("[data-ec-form]");
|
|
214
|
+
if (!form) return;
|
|
215
|
+
|
|
216
|
+
// Evaluate conditions
|
|
217
|
+
evaluateConditions(form);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Popstate Handler ────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function handlePopState(e: PopStateEvent) {
|
|
223
|
+
if (e.state && typeof e.state.ecFormPage === "number" && typeof e.state.ecFormId === "string") {
|
|
224
|
+
const form = document.querySelector<HTMLFormElement>(
|
|
225
|
+
`[data-ec-form][data-form-id="${CSS.escape(e.state.ecFormId)}"]`,
|
|
226
|
+
);
|
|
227
|
+
if (form) {
|
|
228
|
+
const pages = form.querySelectorAll("[data-page]");
|
|
229
|
+
const page = Math.min(e.state.ecFormPage, pages.length - 1);
|
|
230
|
+
showPage(form, Math.max(0, page));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ─── Multi-Page ──────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
function initMultiPage(form: HTMLFormElement) {
|
|
238
|
+
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
|
|
239
|
+
if (pages.length <= 1) return;
|
|
240
|
+
|
|
241
|
+
// Hide all pages except first
|
|
242
|
+
pages.forEach((page, i) => {
|
|
243
|
+
if (i > 0) {
|
|
244
|
+
page.hidden = true;
|
|
245
|
+
// Remove required from hidden pages to prevent native validation
|
|
246
|
+
page.querySelectorAll<HTMLElement>("[required]").forEach((el) => {
|
|
247
|
+
el.removeAttribute("required");
|
|
248
|
+
el.dataset.wasRequired = "1";
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Show next button, hide submit (unless single page)
|
|
254
|
+
const nextBtn = form.querySelector<HTMLButtonElement>("[data-ec-next]");
|
|
255
|
+
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
|
|
256
|
+
if (nextBtn) nextBtn.hidden = false;
|
|
257
|
+
if (submitBtn) submitBtn.hidden = true;
|
|
258
|
+
|
|
259
|
+
updateProgress(form, 0, pages.length);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function showPage(form: HTMLFormElement, pageIndex: number) {
|
|
263
|
+
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
|
|
264
|
+
const totalPages = pages.length;
|
|
265
|
+
|
|
266
|
+
pages.forEach((page, i) => {
|
|
267
|
+
if (i === pageIndex) {
|
|
268
|
+
page.hidden = false;
|
|
269
|
+
// Restore required attributes
|
|
270
|
+
page.querySelectorAll<HTMLElement>("[data-was-required]").forEach((el) => {
|
|
271
|
+
el.setAttribute("required", "");
|
|
272
|
+
delete el.dataset.wasRequired;
|
|
273
|
+
});
|
|
274
|
+
} else {
|
|
275
|
+
page.hidden = true;
|
|
276
|
+
// Strip required from hidden
|
|
277
|
+
page.querySelectorAll<HTMLElement>("[required]").forEach((el) => {
|
|
278
|
+
el.removeAttribute("required");
|
|
279
|
+
el.dataset.wasRequired = "1";
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Update button visibility
|
|
285
|
+
const prevBtn = form.querySelector<HTMLButtonElement>("[data-ec-prev]");
|
|
286
|
+
const nextBtn = form.querySelector<HTMLButtonElement>("[data-ec-next]");
|
|
287
|
+
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
|
|
288
|
+
|
|
289
|
+
if (prevBtn) prevBtn.hidden = pageIndex === 0;
|
|
290
|
+
if (nextBtn) nextBtn.hidden = pageIndex === totalPages - 1;
|
|
291
|
+
if (submitBtn) submitBtn.hidden = pageIndex < totalPages - 1;
|
|
292
|
+
|
|
293
|
+
updateProgress(form, pageIndex, totalPages);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function getCurrentPage(form: HTMLFormElement): number {
|
|
297
|
+
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
|
|
298
|
+
for (let i = 0; i < pages.length; i++) {
|
|
299
|
+
if (!pages[i]!.hidden) return i;
|
|
300
|
+
}
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function updateProgress(form: HTMLFormElement, current: number, total: number) {
|
|
305
|
+
const progress = form.querySelector("[data-ec-progress]");
|
|
306
|
+
if (progress) {
|
|
307
|
+
progress.textContent = `Step ${current + 1} of ${total}`;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ─── Validation ──────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
function validatePage(form: HTMLFormElement, pageIndex: number): boolean {
|
|
314
|
+
const page = form.querySelector<HTMLFieldSetElement>(`[data-page="${pageIndex}"]`);
|
|
315
|
+
if (!page) return true;
|
|
316
|
+
|
|
317
|
+
let valid = true;
|
|
318
|
+
page
|
|
319
|
+
.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
|
|
320
|
+
"input, select, textarea",
|
|
321
|
+
)
|
|
322
|
+
.forEach((input) => {
|
|
323
|
+
if (!input.checkValidity()) {
|
|
324
|
+
valid = false;
|
|
325
|
+
showFieldError(form, input.name, input.validationMessage);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return valid;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validateVisibleFields(form: HTMLFormElement): boolean {
|
|
333
|
+
let valid = true;
|
|
334
|
+
form.querySelectorAll<HTMLFieldSetElement>("[data-page]:not([hidden])").forEach((page) => {
|
|
335
|
+
page
|
|
336
|
+
.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
|
|
337
|
+
"input, select, textarea",
|
|
338
|
+
)
|
|
339
|
+
.forEach((input) => {
|
|
340
|
+
if (!input.checkValidity()) {
|
|
341
|
+
valid = false;
|
|
342
|
+
showFieldError(form, input.name, input.validationMessage);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return valid;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function showFieldError(form: HTMLFormElement, fieldName: string, message: string) {
|
|
351
|
+
const errorEl = form.querySelector(`[data-error-for="${fieldName}"]`);
|
|
352
|
+
if (errorEl) errorEl.textContent = message;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function showErrors(form: HTMLFormElement, errors: Array<{ field: string; message: string }>) {
|
|
356
|
+
for (const err of errors) {
|
|
357
|
+
showFieldError(form, err.field, err.message);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Status Messages ─────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
function showStatus(form: HTMLFormElement, message: string, type: "success" | "error") {
|
|
364
|
+
const status = form.querySelector("[data-form-status]");
|
|
365
|
+
if (status) {
|
|
366
|
+
status.textContent = message;
|
|
367
|
+
status.className = `ec-form-status ec-form-status--${type}`;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function clearStatus(form: HTMLFormElement) {
|
|
372
|
+
const status = form.querySelector("[data-form-status]");
|
|
373
|
+
if (status) {
|
|
374
|
+
status.textContent = "";
|
|
375
|
+
status.className = "ec-form-status";
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─── Conditional Fields ──────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
function initConditions(form: HTMLFormElement) {
|
|
382
|
+
evaluateConditions(form);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function evaluateConditions(form: HTMLFormElement) {
|
|
386
|
+
form.querySelectorAll<HTMLElement>("[data-condition]").forEach((wrapper) => {
|
|
387
|
+
try {
|
|
388
|
+
const condition = JSON.parse(wrapper.dataset.condition || "{}") as {
|
|
389
|
+
field: string;
|
|
390
|
+
op: string;
|
|
391
|
+
value?: string;
|
|
392
|
+
};
|
|
393
|
+
const input = form.elements.namedItem(condition.field) as HTMLInputElement | null;
|
|
394
|
+
if (!input) return;
|
|
395
|
+
|
|
396
|
+
const value = input.value;
|
|
397
|
+
let visible = true;
|
|
398
|
+
|
|
399
|
+
switch (condition.op) {
|
|
400
|
+
case "eq":
|
|
401
|
+
visible = value === (condition.value ?? "");
|
|
402
|
+
break;
|
|
403
|
+
case "neq":
|
|
404
|
+
visible = value !== (condition.value ?? "");
|
|
405
|
+
break;
|
|
406
|
+
case "filled":
|
|
407
|
+
visible = value !== "";
|
|
408
|
+
break;
|
|
409
|
+
case "empty":
|
|
410
|
+
visible = value === "";
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
wrapper.hidden = !visible;
|
|
415
|
+
// Disable inputs in hidden fields so they're excluded from FormData
|
|
416
|
+
wrapper.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((el) => {
|
|
417
|
+
el.disabled = !visible;
|
|
418
|
+
});
|
|
419
|
+
} catch {
|
|
420
|
+
// Invalid condition JSON — show field
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ─── Session Persistence ─────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
function saveState(form: HTMLFormElement) {
|
|
428
|
+
const formId = form.dataset.formId;
|
|
429
|
+
if (!formId) return;
|
|
430
|
+
|
|
431
|
+
const page = getCurrentPage(form);
|
|
432
|
+
const values: Record<string, string> = {};
|
|
433
|
+
for (const [key, val] of new FormData(form)) {
|
|
434
|
+
if (typeof val === "string") values[key] = val;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
sessionStorage.setItem(
|
|
439
|
+
STORAGE_PREFIX + formId,
|
|
440
|
+
JSON.stringify({ page, values, savedAt: Date.now() }),
|
|
441
|
+
);
|
|
442
|
+
} catch {
|
|
443
|
+
// sessionStorage full or unavailable — ignore
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function restoreState(form: HTMLFormElement) {
|
|
448
|
+
const formId = form.dataset.formId;
|
|
449
|
+
if (!formId) return;
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const raw = sessionStorage.getItem(STORAGE_PREFIX + formId);
|
|
453
|
+
if (!raw) return;
|
|
454
|
+
|
|
455
|
+
const state = JSON.parse(raw) as {
|
|
456
|
+
page: number;
|
|
457
|
+
values: Record<string, string>;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Restore field values
|
|
461
|
+
for (const [name, value] of Object.entries(state.values)) {
|
|
462
|
+
const input = form.elements.namedItem(name);
|
|
463
|
+
if (input && "value" in input) {
|
|
464
|
+
(input as unknown as HTMLInputElement).value = value;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Navigate to saved page (clamped to valid range)
|
|
469
|
+
if (state.page > 0) {
|
|
470
|
+
const pages = form.querySelectorAll("[data-page]");
|
|
471
|
+
const page = Math.min(state.page, pages.length - 1);
|
|
472
|
+
if (page > 0) showPage(form, page);
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
// Invalid saved state — ignore
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function clearSavedState(form: HTMLFormElement) {
|
|
480
|
+
const formId = form.dataset.formId;
|
|
481
|
+
if (formId) {
|
|
482
|
+
try {
|
|
483
|
+
sessionStorage.removeItem(STORAGE_PREFIX + formId);
|
|
484
|
+
} catch {
|
|
485
|
+
// Ignore
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function debouncedSave(form: HTMLFormElement) {
|
|
491
|
+
const formId = form.dataset.formId;
|
|
492
|
+
if (!formId) return;
|
|
493
|
+
|
|
494
|
+
const existing = saveTimers.get(formId);
|
|
495
|
+
if (existing) clearTimeout(existing);
|
|
496
|
+
|
|
497
|
+
saveTimers.set(
|
|
498
|
+
formId,
|
|
499
|
+
setTimeout(() => {
|
|
500
|
+
saveState(form);
|
|
501
|
+
saveTimers.delete(formId);
|
|
502
|
+
}, DEBOUNCE_MS),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ─── Turnstile ───────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
function initTurnstile(form: HTMLFormElement) {
|
|
509
|
+
const container = form.querySelector<HTMLElement>("[data-ec-turnstile]");
|
|
510
|
+
if (!container) return;
|
|
511
|
+
|
|
512
|
+
const siteKey = container.dataset.sitekey;
|
|
513
|
+
if (!siteKey) return;
|
|
514
|
+
|
|
515
|
+
// Load Turnstile script if not already loaded
|
|
516
|
+
if (!document.querySelector('script[src*="turnstile"]')) {
|
|
517
|
+
const script = document.createElement("script");
|
|
518
|
+
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
519
|
+
script.async = true;
|
|
520
|
+
script.onload = () => renderTurnstile(container, siteKey);
|
|
521
|
+
document.head.appendChild(script);
|
|
522
|
+
} else {
|
|
523
|
+
renderTurnstile(container, siteKey);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function renderTurnstile(container: HTMLElement, siteKey: string) {
|
|
528
|
+
const w = window as unknown as {
|
|
529
|
+
turnstile?: {
|
|
530
|
+
render: (el: HTMLElement, opts: Record<string, unknown>) => void;
|
|
531
|
+
};
|
|
532
|
+
};
|
|
533
|
+
if (w.turnstile) {
|
|
534
|
+
w.turnstile.render(container, { sitekey: siteKey });
|
|
535
|
+
}
|
|
536
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting utilities for email notifications and webhook payloads.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FormDefinition, Submission, SubmissionFile } from "./types.js";
|
|
6
|
+
import { getFormFields } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const CSV_ESCAPE_RE = /[,"\n]/;
|
|
9
|
+
const DOUBLE_QUOTE_RE = /"/g;
|
|
10
|
+
const CSV_FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format a submission as plain text for email notifications.
|
|
14
|
+
*/
|
|
15
|
+
export function formatSubmissionText(
|
|
16
|
+
form: FormDefinition,
|
|
17
|
+
data: Record<string, unknown>,
|
|
18
|
+
files?: SubmissionFile[],
|
|
19
|
+
): string {
|
|
20
|
+
const fields = getFormFields(form);
|
|
21
|
+
const lines: string[] = [`New submission for "${form.name}"`, ""];
|
|
22
|
+
|
|
23
|
+
for (const field of fields) {
|
|
24
|
+
if (field.type === "hidden") continue;
|
|
25
|
+
const value = data[field.name];
|
|
26
|
+
if (value === undefined || value === null || value === "") continue;
|
|
27
|
+
|
|
28
|
+
const display = Array.isArray(value)
|
|
29
|
+
? (value as string[]).join(", ")
|
|
30
|
+
: String(value as string | number | boolean);
|
|
31
|
+
lines.push(`${field.label}: ${display}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (files && files.length > 0) {
|
|
35
|
+
lines.push("", "Attached files:");
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
lines.push(` - ${file.filename} (${formatBytes(file.size)})`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
lines.push("", `Submitted at: ${new Date().toISOString()}`);
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format a digest email summarizing submissions over a period.
|
|
47
|
+
*/
|
|
48
|
+
export function formatDigestText(
|
|
49
|
+
form: FormDefinition,
|
|
50
|
+
formId: string,
|
|
51
|
+
submissions: Submission[],
|
|
52
|
+
siteUrl: string,
|
|
53
|
+
): string {
|
|
54
|
+
const lines: string[] = [
|
|
55
|
+
`Daily digest for "${form.name}"`,
|
|
56
|
+
"",
|
|
57
|
+
`${submissions.length} new submission${submissions.length === 1 ? "" : "s"} since last digest.`,
|
|
58
|
+
"",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
for (const sub of submissions.slice(0, 10)) {
|
|
62
|
+
const preview = getSubmissionPreview(form, sub);
|
|
63
|
+
lines.push(` - ${sub.createdAt}: ${preview}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (submissions.length > 10) {
|
|
67
|
+
lines.push(` ... and ${submissions.length - 10} more`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
lines.push(
|
|
71
|
+
"",
|
|
72
|
+
`View all submissions: ${siteUrl}/_emdash/admin/plugins/emdash-forms/submissions?formId=${encodeURIComponent(formId)}`,
|
|
73
|
+
);
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format a webhook payload for a new submission.
|
|
79
|
+
*/
|
|
80
|
+
export function formatWebhookPayload(
|
|
81
|
+
form: FormDefinition,
|
|
82
|
+
submissionId: string,
|
|
83
|
+
data: Record<string, unknown>,
|
|
84
|
+
files?: SubmissionFile[],
|
|
85
|
+
): Record<string, unknown> {
|
|
86
|
+
return {
|
|
87
|
+
event: "form.submission",
|
|
88
|
+
formId: form.slug,
|
|
89
|
+
formName: form.name,
|
|
90
|
+
submissionId,
|
|
91
|
+
data,
|
|
92
|
+
files: files?.map((f) => ({
|
|
93
|
+
fieldName: f.fieldName,
|
|
94
|
+
filename: f.filename,
|
|
95
|
+
contentType: f.contentType,
|
|
96
|
+
size: f.size,
|
|
97
|
+
mediaId: f.mediaId,
|
|
98
|
+
})),
|
|
99
|
+
submittedAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Format submissions as CSV.
|
|
105
|
+
*/
|
|
106
|
+
export function formatCsv(
|
|
107
|
+
form: FormDefinition,
|
|
108
|
+
items: Array<{ id: string; data: Submission }>,
|
|
109
|
+
): string {
|
|
110
|
+
const fields = getFormFields(form).filter((f) => f.type !== "hidden");
|
|
111
|
+
const headers = ["ID", "Submitted At", "Status", ...fields.map((f) => f.label)];
|
|
112
|
+
|
|
113
|
+
const rows = items.map(({ id, data: sub }) => {
|
|
114
|
+
const values = [id, sub.createdAt, sub.status];
|
|
115
|
+
for (const field of fields) {
|
|
116
|
+
const v = sub.data[field.name];
|
|
117
|
+
if (field.type === "file") {
|
|
118
|
+
const file = sub.files?.find((f) => f.fieldName === field.name);
|
|
119
|
+
values.push(file ? file.filename : "");
|
|
120
|
+
} else if (Array.isArray(v)) {
|
|
121
|
+
values.push(v.join("; "));
|
|
122
|
+
} else {
|
|
123
|
+
values.push(v === undefined || v === null ? "" : String(v as string | number | boolean));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return values;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return [headers, ...rows].map((row) => row.map(escapeCsv).join(",")).join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function escapeCsv(value: string): string {
|
|
133
|
+
// Neutralize formula triggers to prevent CSV injection in spreadsheet apps
|
|
134
|
+
if (value.length > 0 && CSV_FORMULA_TRIGGERS.has(value.charAt(0))) {
|
|
135
|
+
value = "'" + value;
|
|
136
|
+
}
|
|
137
|
+
if (CSV_ESCAPE_RE.test(value)) {
|
|
138
|
+
return `"${value.replace(DOUBLE_QUOTE_RE, '""')}"`;
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getSubmissionPreview(form: FormDefinition, sub: Submission): string {
|
|
144
|
+
const fields = getFormFields(form).filter((f) => f.type !== "hidden" && f.type !== "file");
|
|
145
|
+
const previews: string[] = [];
|
|
146
|
+
for (const field of fields.slice(0, 3)) {
|
|
147
|
+
const v = sub.data[field.name];
|
|
148
|
+
if (v !== undefined && v !== null && v !== "") {
|
|
149
|
+
const str = String(v as string | number | boolean);
|
|
150
|
+
previews.push(str.length > 50 ? `${str.slice(0, 47)}...` : str);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return previews.join(" | ") || "(empty)";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatBytes(bytes: number): string {
|
|
157
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
158
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
159
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
160
|
+
}
|