@nuasite/components 0.15.2 → 0.16.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 +1 -1
- package/src/form/index.astro +67 -29
package/package.json
CHANGED
package/src/form/index.astro
CHANGED
|
@@ -112,13 +112,15 @@ const tokenId = `${tokenFieldName}_${formId}`
|
|
|
112
112
|
|
|
113
113
|
<script>
|
|
114
114
|
class AstroForm extends HTMLElement {
|
|
115
|
-
private interactionScore = 0;
|
|
116
115
|
private fieldInteractions = new Set<string>();
|
|
117
|
-
private
|
|
116
|
+
private hasPointerActivity = false;
|
|
118
117
|
private keyboardEvents = 0;
|
|
118
|
+
private inputEvents = 0;
|
|
119
119
|
private focusEvents = 0;
|
|
120
|
+
private scrolled = false;
|
|
120
121
|
private startTime = Date.now();
|
|
121
122
|
private honeypotFieldNames: string[] = []
|
|
123
|
+
private abortController = new AbortController()
|
|
122
124
|
|
|
123
125
|
connectedCallback() {
|
|
124
126
|
const form = this.querySelector('form')
|
|
@@ -219,16 +221,32 @@ const tokenId = `${tokenFieldName}_${formId}`
|
|
|
219
221
|
|
|
220
222
|
refreshSubmissionGuards()
|
|
221
223
|
|
|
224
|
+
const trackForm = (action: string, detail?: Record<string, unknown>) => {
|
|
225
|
+
if (typeof window !== 'undefined' && (window as any).analytics?.form) {
|
|
226
|
+
(window as any).analytics.form(formId, action, detail)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
222
230
|
form.addEventListener('submit', async (e) => {
|
|
223
231
|
e.preventDefault()
|
|
224
232
|
|
|
233
|
+
const formDataForTracking = Object.fromEntries(
|
|
234
|
+
Array.from(new FormData(form).entries())
|
|
235
|
+
.filter(([k]) => !this.honeypotFieldNames.includes(k) && !/^token_/.test(k) && !form.querySelector(`[name="${k}"][aria-hidden="true"]`))
|
|
236
|
+
.map(([k, v]) => [k, typeof v === 'string' ? v.slice(0, 100) : '[File]']),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
trackForm('attempt', { formData: formDataForTracking })
|
|
240
|
+
|
|
225
241
|
if (!this.passesHumanityChecks(form)) {
|
|
242
|
+
trackForm('reject', { reason: 'humanity_check_failed', formData: formDataForTracking })
|
|
226
243
|
showError(tryAgainMessage)
|
|
227
244
|
return
|
|
228
245
|
}
|
|
229
246
|
|
|
230
247
|
const elapsed = performance.now() - activationTime
|
|
231
248
|
if (minSubmitDelay > 0 && elapsed < minSubmitDelay) {
|
|
249
|
+
trackForm('reject', { reason: 'submitted_too_fast', formData: formDataForTracking })
|
|
232
250
|
showError(fastSubmitMessage || tryAgainMessage)
|
|
233
251
|
return
|
|
234
252
|
}
|
|
@@ -236,6 +254,7 @@ const tokenId = `${tokenFieldName}_${formId}`
|
|
|
236
254
|
activationTime = performance.now()
|
|
237
255
|
|
|
238
256
|
if (tokenInput && !tokenInput.value) {
|
|
257
|
+
trackForm('reject', { reason: 'missing_token', formData: formDataForTracking })
|
|
239
258
|
showError(tryAgainMessage)
|
|
240
259
|
return
|
|
241
260
|
}
|
|
@@ -254,65 +273,84 @@ const tokenId = `${tokenFieldName}_${formId}`
|
|
|
254
273
|
|
|
255
274
|
if (response.ok && 'success' in result && result.success) {
|
|
256
275
|
const message = hasCustomSuccess ? successMessage : (result.message || successMessage)
|
|
276
|
+
trackForm('success', { formData: formDataForTracking })
|
|
257
277
|
showSuccess(message)
|
|
258
278
|
form.reset()
|
|
259
279
|
refreshSubmissionGuards()
|
|
260
280
|
} else {
|
|
261
281
|
const message = hasCustomError ? errorMessage : (result.error || result.message || errorMessage)
|
|
282
|
+
trackForm('error', { reason: result.error || `http_${response.status}`, formData: formDataForTracking })
|
|
262
283
|
showError(message)
|
|
263
284
|
}
|
|
264
285
|
} catch (err) {
|
|
265
286
|
console.error('Form submission error:', err);
|
|
287
|
+
trackForm('error', { reason: err instanceof Error ? err.message : 'network_error', formData: formDataForTracking })
|
|
266
288
|
showError(networkErrorMessage)
|
|
267
289
|
}
|
|
268
290
|
})
|
|
269
291
|
}
|
|
270
292
|
|
|
271
293
|
private setupInteractionTracking(form: HTMLFormElement) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
this.updateInteractionScore();
|
|
278
|
-
}, 100);
|
|
279
|
-
});
|
|
294
|
+
const signal = this.abortController.signal
|
|
295
|
+
|
|
296
|
+
document.addEventListener('mousemove', () => { this.hasPointerActivity = true }, { signal, once: true });
|
|
297
|
+
document.addEventListener('touchstart', () => { this.hasPointerActivity = true }, { signal, passive: true, once: true });
|
|
298
|
+
document.addEventListener('scroll', () => { this.scrolled = true }, { signal, passive: true, once: true });
|
|
280
299
|
|
|
281
300
|
form.addEventListener('keydown', (e) => {
|
|
282
301
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
283
302
|
this.keyboardEvents++;
|
|
284
|
-
this.updateInteractionScore();
|
|
285
303
|
}
|
|
286
|
-
});
|
|
304
|
+
}, { signal });
|
|
287
305
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
this.
|
|
292
|
-
this.fieldInteractions.add(
|
|
293
|
-
|
|
294
|
-
|
|
306
|
+
// Fires for autofill, paste, voice input, and virtual keyboards
|
|
307
|
+
form.addEventListener('input', (e) => {
|
|
308
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) {
|
|
309
|
+
this.inputEvents++;
|
|
310
|
+
this.fieldInteractions.add(e.target.name);
|
|
311
|
+
}
|
|
312
|
+
}, { signal });
|
|
313
|
+
|
|
314
|
+
// Fires on selects, checkboxes, date pickers
|
|
315
|
+
form.addEventListener('change', (e) => {
|
|
316
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) {
|
|
317
|
+
this.fieldInteractions.add(e.target.name);
|
|
318
|
+
}
|
|
319
|
+
}, { signal });
|
|
320
|
+
|
|
321
|
+
form.querySelectorAll<HTMLElement>('input:not([type="hidden"]):not([aria-hidden="true"]), textarea, select').forEach(input => {
|
|
322
|
+
input.addEventListener('focus', () => { this.focusEvents++ }, { signal });
|
|
295
323
|
});
|
|
296
324
|
}
|
|
297
325
|
|
|
298
|
-
|
|
299
|
-
this.
|
|
300
|
-
|
|
301
|
-
|
|
326
|
+
disconnectedCallback() {
|
|
327
|
+
this.abortController.abort()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private getInteractionScore(): number {
|
|
331
|
+
return (
|
|
332
|
+
(this.hasPointerActivity ? 1 : 0) +
|
|
333
|
+
(this.keyboardEvents > 5 || this.inputEvents > 2 ? 1 : 0) +
|
|
302
334
|
(this.focusEvents > 1 ? 1 : 0) +
|
|
303
|
-
(this.fieldInteractions.size > 1 ? 1 : 0)
|
|
335
|
+
(this.fieldInteractions.size > 1 ? 1 : 0) +
|
|
336
|
+
(this.scrolled ? 1 : 0)
|
|
337
|
+
);
|
|
304
338
|
}
|
|
305
339
|
|
|
306
340
|
private detectSuspiciousBrowser(): boolean {
|
|
341
|
+
const ua = window.navigator.userAgent
|
|
342
|
+
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
|
|
343
|
+
|
|
307
344
|
// Check for headless browser indicators
|
|
308
345
|
const suspiciousIndicators = [
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
346
|
+
// WebDriver API presence (set to true in automated browsers)
|
|
347
|
+
navigator.webdriver === true,
|
|
348
|
+
// Empty plugins list is only suspicious on desktop — mobile browsers normally have none
|
|
349
|
+
!isMobile && !window.navigator.plugins.length,
|
|
312
350
|
!window.navigator.languages.length,
|
|
313
351
|
|
|
314
352
|
// Headless Chrome indicators
|
|
315
|
-
|
|
353
|
+
ua.includes('HeadlessChrome'),
|
|
316
354
|
window.outerWidth === 0,
|
|
317
355
|
window.outerHeight === 0,
|
|
318
356
|
|
|
@@ -330,7 +368,7 @@ const tokenId = `${tokenFieldName}_${formId}`
|
|
|
330
368
|
|
|
331
369
|
if (timeSpent < 3000) return false;
|
|
332
370
|
|
|
333
|
-
if (this.
|
|
371
|
+
if (this.getInteractionScore() < 2) return false;
|
|
334
372
|
|
|
335
373
|
for (const fieldName of this.honeypotFieldNames) {
|
|
336
374
|
const field = form.querySelector(`[name="${fieldName}"]`) as HTMLInputElement
|