@atlanwave/checkout-friction-detector
v1.2.0
Published
Lightweight browser library that detects checkout and form friction, reporting structured events to a backend API.
Maintainers
Readme
checkout-friction-detector
Lightweight, framework-agnostic browser library that detects checkout and form friction — hesitation, repeated edits, validation failures, abandonment, rage clicks, and more — and reports structured events to a backend API.
Installation
npm
npm install checkout-friction-detectorimport CheckoutFrictionDetector from "checkout-friction-detector";
CheckoutFrictionDetector.init({
publicKey: "pk_live_xxx",
});Script tag
<script src="https://cfd.atlanwave.com/js/v1.0.0/checkout-friction-detector.umd.min.js"></script>
<script>
CheckoutFrictionDetector.init({
publicKey: "pk_live_xxx",
});
</script>Quick start
CheckoutFrictionDetector.init({
publicKey: "pk_live_xxx",
debug: true,
formSelector: "form, .checkout-container",
hesitationThresholdMs: 2000,
hooks: {
onSignal(event) {
console.log(event.eventType, event.signal);
},
},
});Public API
| Method | Description |
| ---------------------------------- | -------------------------------------------------- |
| init(config) | Initialize the detector with configuration options |
| destroy() | Remove all listeners and clean up state |
| pause() | Temporarily stop emitting signals |
| resume() | Resume signal emission after pause |
| trackCustomEvent(name, payload?) | Emit a custom event |
| trackCartView(opts) | Emit a cart_view event with the current cart total |
| trackOrderCompleted(opts) | Emit an order_completed event on the success page |
| flush() | Manually flush the event queue to the backend |
| getState() | Return safe diagnostic metadata |
Configuration
CheckoutFrictionDetector.init({
// Required
publicKey: "pk_live_xxx",
// General
debug: false, // Enable console logging
autoTrack: true, // Auto-attach detectors on init
// Form discovery
formSelector: "form", // CSS selector for forms
excludeSelector: "[data-cfd-ignore]", // Exclude matching elements
trackContentEditable: false,
// Multi-step forms
stepSelectors: [".checkout-step", "[data-step]"],
// Detection thresholds
hesitationThresholdMs: 2000, // Focus-to-input delay
longCompletionThresholdMs: 8000, // Time in a single field
repeatedEditThreshold: 3, // Direction changes before emitting
inactivityTimeoutMs: 180000, // Abandonment inactivity window
// Rage clicks
rageClickThreshold: {
clicks: 3,
windowMs: 1000,
radiusPx: 30,
},
// Dead clicks
deadClickWindowMs: 500,
// Validation
validationErrorClasses: ["error", "is-invalid", "input-error"],
// Conversion detection
successUrlPatterns: ["/thank-you", "/order-confirmation"],
successElementSelectors: [".order-success", "[data-checkout-success]"],
// Privacy (privacy-first defaults)
maskFields: ['input[type="password"]', '[name*="card"]', '[name*="cvv"]'],
allowValueCapture: false, // Never capture field values by default
sensitiveFieldPatterns: [/password/i, /card/i, /cvv/i, /ssn/i],
// Transport
flushIntervalMs: 10000,
batchSize: 20,
requestTimeoutMs: 10000,
retryCount: 3,
maxQueueSize: 500,
useLocalStorageQueue: false,
// Hooks
hooks: {
beforeSend(eventBatch) {},
afterSend(response) {},
onSignal(event) {},
isFieldSensitive(fieldInfo) {}, // Return boolean
extractValidationState(fieldEl) {}, // Return 'invalid' or falsy
},
});Event types
| Event | Trigger |
| ------------------------ | ---------------------------------------------------- |
| form_discovered | Form element found on page or dynamically added |
| form_started | First meaningful field interaction in a form |
| field_hesitation | User focuses field, no input for threshold duration |
| field_long_completion | Time in a single field exceeds threshold |
| field_repeated_edits | Value changed (direction changes) exceeds threshold |
| field_validation_error | Native invalid, aria-invalid, or CSS class detected |
| step_viewed | Multi-step form step becomes visible |
| step_revisited | User returns to a previously viewed step |
| step_blocked | Submit attempt blocked on current step |
| submit_attempt | Click on submit button detected |
| rage_click | Rapid repeated clicks in small area |
| dead_click | Click on control with no DOM change after window |
| backtracking | User moves backward in field sequence |
| form_abandoned | Started form not completed (page exit or inactivity) |
| form_completed | Form successfully submitted or success detected |
| cart_view | Cart total reported via trackCartView() |
| order_completed | Order completion reported via trackOrderCompleted()|
| custom_event | Manually tracked via trackCustomEvent() |
Event schema
Every event includes:
{
"eventId": "uuid",
"sessionId": "uuid",
"pageViewId": "uuid",
"formInstanceId": "uuid",
"eventType": "field_hesitation",
"eventTimestamp": 1712345678000,
"page": {
"url": "https://example.com/checkout",
"path": "/checkout",
"referrer": "https://example.com/cart",
"title": "Checkout",
"viewport": { "width": 1440, "height": 900 },
"screen": { "width": 1920, "height": 1080 },
"timezone": "America/New_York",
"userAgent": "...",
"language": "en-US",
"deviceType": "desktop"
},
"form": {
"cssPath": "form#checkout",
"started": true,
"submitAttempts": 0,
"currentStep": "shipping"
},
"field": {
"tagName": "input",
"type": "email",
"name": "email",
"id": "email",
"label": "Email Address",
"cssPath": "form#checkout > input#email",
"fingerprint": "a1b2c3d4"
},
"signal": {
"hesitationDurationMs": 2000,
"previouslyVisited": false
},
"libraryVersion": "1.0.0"
}Revenue tracking
The library can attribute revenue impact to the friction it detects. Two
events: cart_view (fired when a checkout page loads with a known cart
total) and order_completed (fired on the post-purchase success page).
Both go through the existing /api/v1/ingest endpoint with the same
session and pageView envelope.
valueMinor is the cart total in minor units (cents/pence) as an integer
— never floats. Without it the backend can only show event-count framing
in reports, not revenue impact.
API
CheckoutFrictionDetector.trackCartView({
valueMinor: 4999, // preferred — integer in cents
// value: 49.99, // alternative — float, library will × 100
currency: "USD",
itemCount: 3, // optional
});
CheckoutFrictionDetector.trackOrderCompleted({
valueMinor: 4999,
currency: "USD",
orderRef: "ord_abc123", // optional, must be a non-PII id
});orderRef must be an opaque order identifier — never an email, name, or
anything that identifies the customer. If the value looks like an email
the library drops it and emits a console.warn.
Vanilla JS
<script src="https://cfd.atlanwave.com/js/v1.0.0/checkout-friction-detector.umd.min.js"></script>
<script>
CheckoutFrictionDetector.init({ publicKey: "pk_live_xxx" });
CheckoutFrictionDetector.trackCartView({
valueMinor: 4999,
currency: "USD",
itemCount: 3,
});
</script>React
import { useEffect } from "react";
import CheckoutFrictionDetector from "checkout-friction-detector";
function CheckoutPage({ cart }) {
useEffect(() => {
CheckoutFrictionDetector.trackCartView({
valueMinor: cart.totalCents,
currency: cart.currency,
itemCount: cart.items.length,
});
}, [cart.totalCents, cart.currency, cart.items.length]);
return /* ... */;
}
function OrderSuccessPage({ order }) {
useEffect(() => {
CheckoutFrictionDetector.trackOrderCompleted({
valueMinor: order.totalCents,
currency: order.currency,
orderRef: order.number,
});
}, [order.number]);
return /* ... */;
}Shopify Liquid
<script>
CheckoutFrictionDetector.trackCartView({
valueMinor: {{ cart.total_price }},
currency: {{ cart.currency.iso_code | json }},
itemCount: {{ cart.item_count }}
});
</script>
{% comment %} On the order status page: {% endcomment %}
<script>
CheckoutFrictionDetector.trackOrderCompleted({
valueMinor: {{ checkout.total_price }},
currency: {{ checkout.currency | json }},
orderRef: {{ checkout.order_number | json }}
});
</script>Auto-detection (opt-in)
Set autoDetectCart: true in the init config and the library will make a
best-effort attempt to fire cart_view / order_completed on init from:
- Schema.org JSON-LD
Product/Orderblocks - Shopify checkout:
window.Shopify.checkout.total_price(already in minor units) - Stripe Checkout success page: a
?session_id=query param triggers a presence-onlyorder_completed(no value — your backend reconciles via the Stripe webhook)
CheckoutFrictionDetector.init({
publicKey: "pk_live_xxx",
autoDetectCart: true,
});The library deliberately does not parse arbitrary DOM cart totals —
too easy to grab the wrong element. The explicit trackCartView API is
the recommended path.
Privacy model
Privacy-first by default:
- Field values are never captured unless
allowValueCapture: trueis set - Even with value capture enabled, sensitive fields (password, card, CVV, SSN) are always masked
- Fields matching
maskFieldsselectors orsensitiveFieldPatternsare treated as sensitive - The
isFieldSensitivehook allows custom sensitivity logic - No
innerTextcapture beyond safe label extraction - No keystroke-level recording
- Outgoing payloads are sanitized and size-limited
SPA support
The library automatically detects route changes via History API (pushState/replaceState) and popstate. On navigation:
pageViewIdis refreshedsessionIdis preserved- Forms are re-scanned
- Success URL patterns are re-checked
No additional configuration needed for most SPAs.
Development
npm install
npm run build # Build ESM + UMD + UMD minified
npm test # Run tests
npm run mock-server # Start mock backend on :3333Examples
examples/simple-checkout.html— Basic single-page checkout formexamples/multi-step-checkout.html— Multi-step wizard with step trackingexamples/spa-checkout.html— SPA with client-side routingexamples/test-harness.html— Interactive friction scenario simulator
Start the mock server and open examples in your browser:
npm run mock-server
# Open http://localhost:3333/simple-checkout.htmlBuild outputs
| File | Format | Use case |
| -------------------------------------------- | ------------ | -------------------------------- |
| dist/checkout-friction-detector.esm.js | ESM | Bundlers (webpack, vite, rollup) |
| dist/checkout-friction-detector.umd.js | UMD | Script tag, RequireJS |
| dist/checkout-friction-detector.umd.min.js | UMD minified | Production script tag |
License
MIT
