/*! * realeyes-verifeye.js — VerifEye × HubSpot lead-form integration * v1.3.0 * * Phase 1B — Option C: single native HubSpot checkbox + CSS-driven verified state. * * Design: * - The HubSpot form contains ONE visible single-checkbox field * (`verifeye_completed`) plus four hidden metadata fields * (`verifeye_attempted`, `verifeye_session_id`, `verifeye_age`, * `verifeye_gender`). No dependent-field rules. * - Label copy on the checkbox: "Verify you're human and we'll * prioritise your request." * - Clicking the checkbox opens the VerifEye verification modal. * On successful verification, the checkbox becomes checked and * the label text is replaced (via CSS) with "✓ Verified Human". * The storage blob drives the replacement via a `reve-verified` * class on the form — no :has() needed, no DOM mutation of the * label text (so React reconciliation can't undo it). * * How it survives React / HubSpot reconciliation: * - Click interception is a single document-level capture-phase * listener. The listener lives on `document`, not on the input, * so any child-replacement by HubSpot's React renderer does not * affect it. * - Submit blocking is also document-level, gated on a module-local * `submitBlocked` flag. * - On every `hsFormCallback` / `onFormReady` postMessage from * HubSpot (fired on initial mount and every remount), we re-apply * state to any rendered form, including the `reve-verified` class. * * Exposes a single global: window.RealEyesVerifEye. * Safe to load twice (second load is a no-op). */ (function () { 'use strict'; if (window.RealEyesVerifEye) return; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const VERSION = '1.3.0'; const STORAGE_KEY = 'realeyes.verifeye.v1'; const TTL_MS = 24 * 60 * 60 * 1000; // Pre-signed URL for VerifEye's "balanced liveness" demo verification project. // Signature was computed over the full query string including reDemoMode and reRunId — // do not modify any query parameter or the signature will fail. // When VerifEye rotates signing keys or changes the signature format, swap this URL. // When Robi's wrapper change is live, this can be swapped to 'https://realeyes.ai/verifeye-demo/' // if the wrapper flow is preferred (it offers a richer landing experience with intro carousel). const VERIFEYE_IFRAME_URL = 'https://verifeye-service-eu.realeyes.ai/verification/efa1255c-7689-43c4-a35f-6703a3c03cf9?reDemoMode=true&reRunId=4f448636-6344-4edd-bcf4-b4ecce9fec02&reSignature=58b61ea5c8ab0616394511d9d4e05bb86a09248a450a2c208332420167321a51'; const ALLOWED_ORIGINS = [ 'https://realeyes.ai', 'https://verifeye-docs.realeyes.ai', 'https://verifeye-service-eu.realeyes.ai', 'https://verifeye-service-us.realeyes.ai' ]; const FIELD_NAMES = { attempted: 'verifeye_attempted', completed: 'verifeye_completed', sessionId: 'verifeye_session_id', age: 'verifeye_age', gender: 'verifeye_gender' }; const HS_FORM_SELECTOR = 'form.hs-form'; const VERIFIED_CLASS = 'reve-verified'; // --------------------------------------------------------------------------- // Module state // --------------------------------------------------------------------------- let useMemoryStore = false; let memoryStore = null; // holds serialized blob when localStorage is unavailable const registeredForms = new Set(); let modalEl = null; let modalCleanup = null; let modalOwnerForm = null; let previousFocus = null; // Module-level so the flag survives any reconciliation of the form element // itself while the modal is open. let submitBlocked = false; // --------------------------------------------------------------------------- // Storage // --------------------------------------------------------------------------- const EMPTY_BLOB = { attempted: false, completed: false, sessionId: null, age: null, gender: null, liveness: null, updatedAt: 0, expiresAt: 0 }; const storage = { /** * Returns the current stored blob or null if absent/expired. * Deletes the key on expiry. * @returns {object|null} */ read() { let raw; try { raw = useMemoryStore ? memoryStore : localStorage.getItem(STORAGE_KEY); } catch (e) { useMemoryStore = true; raw = memoryStore; } if (!raw) return null; let blob; try { blob = JSON.parse(raw); } catch (e) { storage.clear(); return null; } if (!blob || typeof blob !== 'object') return null; if (typeof blob.expiresAt !== 'number' || Date.now() >= blob.expiresAt) { storage.clear(); return null; } return blob; }, /** * Merges a patch into the stored blob. Monotonic for attempted/completed * (true never reverts to false). Refreshes TTL. Returns the written blob. * @param {object} patch * @returns {object} */ write(patch) { const current = storage.read() || Object.assign({}, EMPTY_BLOB); const next = Object.assign({}, current, patch || {}); if (current.attempted === true) next.attempted = true; if (current.completed === true) next.completed = true; if (next.completed === true) next.attempted = true; next.updatedAt = Date.now(); next.expiresAt = next.updatedAt + TTL_MS; const serialized = JSON.stringify(next); try { if (useMemoryStore) { memoryStore = serialized; } else { localStorage.setItem(STORAGE_KEY, serialized); } } catch (e) { useMemoryStore = true; memoryStore = serialized; } return next; }, /** Deletes the storage blob. */ clear() { try { if (!useMemoryStore) localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ } memoryStore = null; } }; // --------------------------------------------------------------------------- // Field writes // --------------------------------------------------------------------------- function setTextField(form, name, value) { const el = form.querySelector('[name="' + name + '"]'); if (!el || el.type === 'checkbox') return false; if (el.value === value) return true; el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; } function setCheckboxField(form, name, checked) { const el = form.querySelector('input[type="checkbox"][name="' + name + '"]'); if (!el) return false; const desired = !!checked; if (el.checked === desired) return true; el.checked = desired; el.dispatchEvent(new Event('change', { bubbles: true })); return true; } function applyStateToForm(form, blob) { const completed = !!(blob && blob.completed); setTextField(form, FIELD_NAMES.attempted, blob && blob.attempted ? 'true' : ''); setTextField(form, FIELD_NAMES.sessionId, (blob && blob.sessionId) || ''); setTextField(form, FIELD_NAMES.age, blob && blob.age != null ? String(blob.age) : ''); setTextField(form, FIELD_NAMES.gender, (blob && blob.gender) || ''); setCheckboxField(form, FIELD_NAMES.completed, completed); // CSS on this class swaps the label text to "✓ Verified Human". if (completed) form.classList.add(VERIFIED_CLASS); else form.classList.remove(VERIFIED_CLASS); } // --------------------------------------------------------------------------- // Document-level listeners (survive React reconciliation by not being // attached to any element HubSpot can replace) // --------------------------------------------------------------------------- // Click on verifeye_completed → open modal instead of toggling. document.addEventListener('click', function (e) { const t = e.target; if (!t || t.tagName !== 'INPUT' || t.type !== 'checkbox') return; if (t.name !== FIELD_NAMES.completed) return; const form = t.closest(HS_FORM_SELECTOR); if (!form) return; e.preventDefault(); e.stopImmediatePropagation(); const blob = storage.read(); if (blob && blob.completed) { // Already verified within TTL. Re-assert state (defensive). applyStateToForm(form, blob); return; } openModal(form); }, true); // Block submit while the modal is open. document.addEventListener('submit', function (e) { if (!submitBlocked) return; const t = e.target; if (!t || typeof t.matches !== 'function' || !t.matches(HS_FORM_SELECTOR)) return; e.preventDefault(); e.stopImmediatePropagation(); }, true); // --------------------------------------------------------------------------- // Modal // --------------------------------------------------------------------------- function openModal(form) { if (modalEl) return; submitBlocked = true; modalOwnerForm = form; previousFocus = document.activeElement; const prevBodyOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; // Record the attempt immediately (before user completes / closes). storage.write({ attempted: true }); applyStateToForm(form, storage.read()); const overlay = document.createElement('div'); overlay.className = 'reve-modal-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'VerifEye human verification'); // Make the overlay focusable so we can trap focus without a visible button. overlay.setAttribute('tabindex', '-1'); const panel = document.createElement('div'); panel.className = 'reve-modal-panel'; const iframe = document.createElement('iframe'); iframe.className = 'reve-modal-iframe'; iframe.title = 'VerifEye human verification'; iframe.setAttribute('allow', 'camera; fullscreen'); iframe.src = VERIFEYE_IFRAME_URL; panel.appendChild(iframe); overlay.appendChild(panel); document.body.appendChild(overlay); modalEl = overlay; // Focus management — move focus to the overlay itself (no visible close button). setTimeout(function () { try { overlay.focus(); } catch (e) { /* noop */ } }, 0); function onKeydown(e) { if (e.key === 'Escape') { e.preventDefault(); closeModal(); return; } if (e.key === 'Tab') { const focusables = Array.prototype.filter.call( overlay.querySelectorAll('button, iframe, [tabindex]:not([tabindex="-1"])'), function (el) { return !el.disabled && el.offsetParent !== null; } ); if (focusables.length === 0) { e.preventDefault(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } } // Backdrop click closes the modal — only when the click lands on the overlay // itself, not bubbled up from the panel/iframe. function onOverlayClick(e) { if (e.target === overlay) closeModal(); } document.addEventListener('keydown', onKeydown, true); overlay.addEventListener('click', onOverlayClick); modalCleanup = function () { document.removeEventListener('keydown', onKeydown, true); overlay.removeEventListener('click', onOverlayClick); document.body.style.overflow = prevBodyOverflow; if (overlay.parentNode) overlay.parentNode.removeChild(overlay); modalEl = null; modalCleanup = null; submitBlocked = false; const owner = modalOwnerForm; modalOwnerForm = null; // Restore focus to the checkbox if still present, else whatever had focus. const focusTarget = owner ? owner.querySelector('input[name="' + FIELD_NAMES.completed + '"]') : null; if (focusTarget) { try { focusTarget.focus(); } catch (e) { /* noop */ } } else if (previousFocus && typeof previousFocus.focus === 'function') { try { previousFocus.focus(); } catch (e) { /* noop */ } } previousFocus = null; }; } function closeModal() { if (!modalCleanup) return; const owner = modalOwnerForm; modalCleanup(); if (owner) applyStateToForm(owner, storage.read()); } // --------------------------------------------------------------------------- // Message listener — VerifEye iframe postMessages // --------------------------------------------------------------------------- function handleMessage(event) { if (ALLOWED_ORIGINS.indexOf(event.origin) === -1) return; const data = event.data; if (!data || typeof data !== 'object') return; // Defence-in-depth: some iframe code paths may post { message: "close" }. // The canonical close signal is `redirectedTo` (see project memory), but // we handle this too so the modal never gets stuck. if (data.message === 'close') { if (modalEl) closeModal(); return; } if (typeof data.redirectedTo !== 'string') return; let url; try { url = new URL(data.redirectedTo); } catch (e) { return; } const params = url.searchParams; const liveness = params.get('reLivenessResult'); const completed = liveness === 'passed'; const patch = { attempted: true, liveness: liveness || null }; if (completed) { patch.completed = true; patch.sessionId = params.get('reVerificationSessionId') || null; const ageRaw = params.get('reAge'); if (ageRaw != null && ageRaw !== '') { const n = parseInt(ageRaw, 10); patch.age = isNaN(n) ? null : n; } else { patch.age = null; } patch.gender = params.get('reGender') || null; } const blob = storage.write(patch); registeredForms.forEach(function (form) { applyStateToForm(form, blob); }); // A redirectedTo message means the iframe is done — whether the user // completed successfully, failed, or dismissed via the intro's × (which // the iframe reports as reVerificationResult=failed with empty liveness). // Either way, close the modal. if (modalEl) closeModal(); } window.addEventListener('message', handleMessage); // --------------------------------------------------------------------------- // Form discovery // --------------------------------------------------------------------------- // HubSpot v2 embeds post { type: 'hsFormCallback', eventName: 'onFormReady', id } // to the parent window when a form finishes rendering. This fires on initial // mount and on subsequent React remounts. window.addEventListener('message', function (e) { const d = e.data; if (!d || typeof d !== 'object') return; if (d.type !== 'hsFormCallback') return; if (d.eventName !== 'onFormReady') return; discoverForms(); }); function discoverForms() { const forms = document.querySelectorAll(HS_FORM_SELECTOR); for (let i = 0; i < forms.length; i++) initForm(forms[i]); } // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- function injectStyles() { if (document.getElementById('reve-styles')) return; const style = document.createElement('style'); style.id = 'reve-styles'; // Concrete HubSpot DOM this targets (booleancheckbox field): // //
//
// //
//
// // Selector strategy (for defence against future HubSpot renames): // - Primary: `.hs-form-booleancheckbox-display` // - Fallback: `label[for^="verifeye_completed-"]` // (HubSpot always names the id with `-`, so the // `for^=` prefix attribute selector is a reliable backup if they // ever rename the booleancheckbox class) // If *both* selectors stop matching, the worst-case failure is that the // checkbox falls back to its native appearance — no visible breakage, // no JS errors, just missing "✓ Verified Human" skin. // // `!important` on layout-critical rules is deliberate: theme CSS on the // parent page (realeyes.ai) and HubSpot's own defaults can otherwise // override display/margin/float and produce misaligned rendering. // Build-up selector pieces. IMPORTANT: never put a comma inside one of // these strings and then concatenate it into a longer selector — CSS // commas are selector-list separators, not combinators, so any rule that // does `PREFIX + ",…" + SUFFIX` will split into two rules and the second // will drop PREFIX. Bug fixed in this revision: each comma-separated // selector below is written out fully qualified. const F = FIELD_NAMES.completed; const HSF = 'form.hs-form'; const HSFV = 'form.hs-form.' + VERIFIED_CLASS; const FLD = ' .hs_' + F; const L1 = ' .hs-form-booleancheckbox-display'; // primary const L2 = ' label[for^="' + F + '-"]'; // fallback style.textContent = [ // ====================================================================== // Layout normalization — force the booleancheckbox to render with the // checkbox inline-left of the prompt text on every browser / theme. // ====================================================================== HSF + FLD + ' .inputs-list,', HSF + FLD + ' li.hs-form-booleancheckbox {', ' list-style: none !important;', ' padding: 0 !important;', ' margin: 0 !important;', '}', HSF + FLD + L1 + ',', HSF + FLD + L2 + ' {', ' display: inline-flex !important;', ' align-items: center;', ' gap: 8px;', ' margin: 0 !important;', ' padding: 0 !important;', ' font-weight: 400;', ' cursor: pointer;', ' line-height: 1.4;', ' text-align: left;', '}', HSF + FLD + L1 + ' > input[type="checkbox"],', HSF + FLD + L2 + ' > input[type="checkbox"] {', ' flex: 0 0 auto;', ' margin: 0 !important;', ' float: none !important;', ' width: auto !important;', ' position: static !important;', '}', HSF + FLD + L1 + ' > span,', HSF + FLD + L2 + ' > span {', ' flex: 0 1 auto;', '}', // ====================================================================== // Verified state (driven by `.reve-verified` class on the form): // hide the checkbox entirely and replace the span text with "✓ Verified // Human". Text replacement uses font-size:0 on the original span and a // ::after pseudo-element with its own font-size — no absolute // positioning, so nothing to mis-align. // ====================================================================== HSFV + FLD + ' input[type="checkbox"][name="' + F + '"] {', ' display: none !important;', '}', HSFV + FLD + L1 + ',', HSFV + FLD + L2 + ' {', ' pointer-events: none;', // belt & braces — the input is gone, nothing to click ' cursor: default;', '}', HSFV + FLD + L1 + ' > span,', HSFV + FLD + L2 + ' > span {', ' font-size: 0;', ' line-height: 0;', '}', HSFV + FLD + L1 + ' > span::after,', HSFV + FLD + L2 + ' > span::after {', // CSS gotcha: a single space after a hex escape (\2713) is the escape // *terminator* and is consumed, not rendered. Use \0000a0 (nbsp) with a // 6-digit form so there's no ambiguity, and double it up for the visual // breathing room the design wants. ' content: "\\002713\\0000a0\\0000a0Verified Human";', ' font-size: 14px;', ' line-height: 1.4;', ' color: #0d8f3f;', ' font-weight: 600;', '}', // ====================================================================== // Modal // ====================================================================== '.reve-modal-overlay { position: fixed; inset: 0; z-index: 2147483000; background: rgba(0,0,0,0.65); display: flex; align-items: center; justify-content: center; }', '.reve-modal-overlay:focus { outline: none; }', '.reve-modal-panel { position: relative; background: #fff; width: 360px; max-width: 360px; max-height: 650px; border-radius: 16px; overflow: hidden; box-shadow: 0 11px 15px -7px rgba(0,0,0,0.2), 0 24px 38px 3px rgba(0,0,0,0.14), 0 9px 46px 8px rgba(0,0,0,0.12); }', '@media (max-width: 600px) { .reve-modal-panel { width: 95vw; max-width: 95vw; max-height: 90vh; } }', '.reve-modal-iframe { width: 100%; height: 650px; max-height: 650px; border: 0; display: block; background: #fff; }' ].join('\n'); (document.head || document.documentElement).appendChild(style); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Register a HubSpot form with the integration. Idempotent. Writes any * existing verification state from localStorage into the form's fields * and toggles the `.reve-verified` class used by the CSS. * @param {HTMLFormElement} form */ function initForm(form) { if (!form || !(form instanceof HTMLElement)) return; // Field-gated attach: the script is safe to load site-wide because we // only take ownership of forms that opt in by containing our field. // Any other HubSpot form on the page is left untouched. if (!form.querySelector('[name="' + FIELD_NAMES.completed + '"]')) return; injectStyles(); registeredForms.add(form); applyStateToForm(form, storage.read()); } // Eager style injection so the verified-state CSS is ready the moment // HubSpot renders the form. injectStyles(); // Boot-time discovery: scan now, and again on DOMContentLoaded. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', discoverForms); } else { discoverForms(); } window.RealEyesVerifEye = { version: VERSION, initForm: initForm, storage: storage, _internals: { handleMessage: handleMessage, discoverForms: discoverForms, openModal: function () { const f = registeredForms.values().next().value; if (f) openModal(f); }, closeModal: function () { closeModal(); }, isModalOpen: function () { return modalEl !== null; }, isSubmitBlocked: function () { return submitBlocked; }, simulateCompletion: function (params) { const qs = new URLSearchParams(params || {}).toString(); handleMessage({ origin: 'https://realeyes.ai', data: { redirectedTo: 'https://realeyes.ai/verifeye-demo/?' + qs } }); } } }; })();