/*! * 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): // //