@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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@nuasite/components",
3
3
  "description": "Nua Site astro components.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.15.2",
5
+ "version": "0.16.1",
6
6
  "files": [
7
7
  "dist/**",
8
8
  "src/**",
@@ -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 mouseMovements = 0;
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
- let mouseMoveTimeout: NodeJS.Timeout;
273
- document.addEventListener('mousemove', () => {
274
- clearTimeout(mouseMoveTimeout);
275
- mouseMoveTimeout = setTimeout(() => {
276
- this.mouseMovements++;
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
- const inputs = form.querySelectorAll('input[type="text"], input[type="email"], textarea');
289
- inputs.forEach(input => {
290
- input.addEventListener('focus', () => {
291
- this.focusEvents++;
292
- this.fieldInteractions.add((input as HTMLInputElement).name);
293
- this.updateInteractionScore();
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
- private updateInteractionScore() {
299
- this.interactionScore =
300
- (this.mouseMovements > 0 ? 1 : 0) +
301
- (this.keyboardEvents > 5 ? 1 : 0) +
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
- // Missing expected browser features
310
- !window.navigator.webdriver === undefined,
311
- !window.navigator.plugins.length,
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
- window.navigator.userAgent.includes('HeadlessChrome'),
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.interactionScore < 2) return false;
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