1/* Progressive lead form. 2 * 3 * One <form>, three <fieldset> steps toggled with `hidden`. Deliberately not 4 * three pages: a navigation between steps breaks TrustedForm's continuous 5 * session recording, which is the TCPA consent proof the whole lead is worth 6 * nothing without. 7 * 8 * POST fires exactly once, at the end of step 3. Steps 1-2 are client-side only, 9 * so a partial fill never creates a HighLevel contact that no agent can call. 10 * 11 * Progressive enhancement: the markup this drives is a real <form> with a real 12 * action, so without JS the ZIP bar still navigates to /plan-finder. 13 * 14 * Depends on: attribution.js (window.hecAttribution), phone-pool.js 15 * (window.HEC_AGENTS_AVAILABLE). 16 */ 17(function () { 18 "use strict"; 19 20 var STORE = "hec_form"; 21 var ENDPOINT = "/api/lead"; 22 23 // --- analytics ------------------------------------------------------------ 24 // Delegated to tracking.js, which decides which of these are also Google Ads 25 // conversions. The fallback keeps GA4 working if tracking.js fails to load â 26 // losing the Ads signal is bad, losing the funnel data as well is worse. 27 function track(name, params) { 28 if (typeof window.hecTrack === "function") return window.hecTrack(name, params || {}); 29 if (typeof window.gtag === "function") window.gtag("event", name, params || {}); 30 } 31 32 // --- session persistence -------------------------------------------------- 33 // So a back-navigation or an accidental reload doesn't reset someone who has 34 // already answered three questions. 35 // 36 // Keyed per form type, not one shared bucket. payload() sends every key in 37 // `data`, not just the ones this form renders â so with a single bucket a 38 // visitor who started the consumer form and then opened /job-application 39 // would POST a job application carrying `consent: true` plus the coverage 40 // answers from the other form. That is consent they never gave on this page, 41 // against consent language that isn't on it. 42 function storeKey(formType) { return STORE + ":" + formType; } 43 function load(key) { 44 try { return JSON.parse(sessionStorage.getItem(key) || "{}"); } catch (e) { return {}; } 45 } 46 function save(key, data) { 47 try { sessionStorage.setItem(key, JSON.stringify(data)); } catch (e) { /* private mode */ } 48 } 49 50 // --- validation ----------------------------------------------------------- 51 var V = { 52 postal_code: function (v) { 53 return /^\d{5}$/.test(v) ? "" : "Please enter your 5-digit ZIP code."; 54 }, 55 first_name: function (v) { return v.trim() ? "" : "Please enter your first name."; }, 56 last_name: function (v) { return v.trim() ? "" : "Please enter your last name."; }, 57 email: function (v) { 58 // Mirrors the server-side check in api/lead.js so the user never gets a 59 // round-trip rejection for something we could catch here. 60 return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v) ? "" : "That email doesn't look right â check for a typo."; 61 }, 62 phone: function (v) { 63 var d = v.replace(/\D/g, ""); 64 if (d.length !== 10) return "Please enter a 10-digit US mobile number."; 65 // NANP: area code and exchange can't start with 0 or 1. 555-01xx is the 66 // reserved fictional range and is always a bad number. 67 if (/^[01]/.test(d) || /^\d{3}[01]/.test(d)) return "That doesn't look like a real US number."; 68 if (/^\d{3}55501/.test(d)) return "That doesn't look like a real US number."; 69 return ""; 70 }, 71 // Only forms that render a state field have one; the consumer form asks for 72 // a ZIP instead. 73 state: function (v) { return v ? "" : "Please choose your state."; }, 74 consent: function (v, el) { 75 if (el.checked) return ""; 76 // The consumer form's "so an agent can call you" is the right nudge there 77 // and nonsense on the recruiting form, which is not offering a sales call. 78 return el.dataset.consentError || "Please check the box so an agent can call you."; 79 } 80 }; 81 82 // Common typos, offered as a suggestion the user clicks â never auto-applied, 83 // because silently rewriting someone's email address loses leads when we're wrong. 84 var DOMAIN_TYPOS = { 85 "gmial.com": "gmail.com", "gmai.com": "gmail.com", "gmail.co": "gmail.com", 86 "gnail.com": "gmail.com", "gmail.cm": "gmail.com", "yahooo.com": "yahoo.com", 87 "yaho.com": "yahoo.com", "hotmial.com": "hotmail.com", "outlok.com": "outlook.com", 88 "iclould.com": "icloud.com", "icloud.co": "icloud.com" 89 }; 90 91 function phoneMask(v) { 92 var d = v.replace(/\D/g, "").slice(0, 10); 93 if (d.length < 4) return d; 94 if (d.length < 7) return "(" + d.slice(0, 3) + ") " + d.slice(3); 95 return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6); 96 } 97 98 // --- per-form controller -------------------------------------------------- 99 function initForm(form) { 100 var steps = Array.prototype.slice.call(form.querySelectorAll("[data-step]")); 101 var formType = form.dataset.formType || "application"; 102 var store = storeKey(formType); 103 var data = load(store); 104 var current = 0; 105 var startTracked = false;
106 var zipTracked = false; 107 var submitting = false; 108 109 var progress = form.querySelector("[data-progress]"); 110 var progressBar = form.querySelector("[data-progress-bar]"); 111 112 function fieldsIn(step) { 113 return Array.prototype.slice.call( 114 step.querySelectorAll("input[name], select[name], textarea[name]") 115 ).filter(function (el) { return el.type !== "hidden" && el.name !== "company_website"; }); 116 } 117 118 function errorEl(el) { 119 var id = el.name + "-error"; 120 var node = form.querySelector('[data-error-for="' + el.name + '"]'); 121 if (node && !node.id) node.id = id; 122 return node; 123 } 124 125 function showError(el, msg) { 126 var node = errorEl(el); 127 if (!node) return; 128 node.textContent = msg; 129 node.hidden = !msg; 130 el.setAttribute("aria-invalid", msg ? "true" : "false"); 131 if (msg) el.setAttribute("aria-describedby", node.id); 132 else el.removeAttribute("aria-describedby"); 133 el.classList.toggle("has-error", !!msg); 134 } 135 136 function validate(el, silent) { 137 var fn = V[el.name]; 138 if (!fn) return true; 139 var msg = fn(el.value, el); 140 if (!silent) showError(el, msg); 141 if (msg && !silent) { 142 track("form_field_error", { 143 field_name: el.name, 144 error_type: el.value ? "format" : "required", 145 step_number: current + 1, 146 form_type: formType 147 }); 148 } 149 return !msg; 150 } 151 152 // A radio group has no per-element validator (each option is individually 153 // valid); what matters is that the group has *a* selection. Checked here and 154 // surfaced in the group's own error node so the Continue button on step 2 155 // can't skip an unanswered question. 156 function validateGroups(step) { 157 var ok = true; 158 var names = {}; 159 fieldsIn(step).forEach(function (f) { if (f.type === "radio") names[f.name] = true; }); 160 Object.keys(names).forEach(function (name) { 161 var node = form.querySelector('[data-error-for="' + name + '"]'); 162 if (!step.querySelector('[name="' + name + '"]:checked')) { 163 ok = false; 164 if (node) { node.textContent = "Please choose an option to continue."; node.hidden = false; } 165 } else if (node) { node.hidden = true; } 166 }); 167 return ok; 168 } 169 170 function clearGroupError(name) { 171 var node = form.querySelector('[data-error-for="' + name + '"]'); 172 if (node) node.hidden = true; 173 } 174 175 function validateStep(idx) { 176 var ok = true; 177 var firstBad = null; 178 fieldsIn(steps[idx]).forEach(function (el) { 179 if (!validate(el)) { ok = false; if (!firstBad) firstBad = el; } 180 }); 181 if (!validateGroups(steps[idx])) ok = false; 182 if (firstBad) firstBad.focus(); 183 return ok; 184 } 185 186 function collect() { 187 fieldsIn(form).forEach(function (el) { 188 if (el.type === "radio") { if (el.checked) data[el.name] = el.value; } 189 else if (el.type === "checkbox") data[el.name] = el.checked; 190 else if (el.value) data[el.name] = el.value; 191 }); 192 save(store, data); 193 } 194 195 /* Persist the draft while it is being typed. 196 * 197 * collect() otherwise only runs when a step changes or on submit, so a 198 * single-step form (the recruiting one) never saved anything at all, and 199 * even on the consumer form everything typed on step 3 â the email and the 200 * phone number, the two fields the lead is actually worth â was lost to a 201 * reload. Debounced because sessionStorage writes are synchronous and this 202 * fires on every keystroke. 203 */ 204 var saveTimer = null; 205 function saveSoon() { 206 if (saveTimer) clearTimeout(saveTimer); 207 saveTimer = setTimeout(collect, 250); 208 } 209 210 function restore() { 211 fieldsIn(form).forEach(function (el) { 212 var v = data[el.name]; 213 if (v === undefined) return; 214 // Never restore the TCPA consent box. Restoring it would render the 215 // page with consent already granted, which is a pre-checked consent box 216 // in everything but name â and TrustedForm would certify a session in 217 // which the visitor never actually clicked it. They re-check it; it is 218 // one tap, and it is the tap the whole lead's legality rests on. 219 if (el.name === "consent") return; 220 if (el.type === "radio") el.checked = el.value === v; 221 else if (el.type === "checkbox") el.checked = !!v; 222 else el.value = v; 223 }); 224 } 225 226 function updateProgress() { 227 if (!progress) return; 228 // Hidden on step 1: showing "1 of 3" before any commitment just advertises 229 // how much work is left. 230 var show = current > 0; 231 progress.hidden = !show; 232 if (!show) return; 233 progress.querySelector("[data-progress-text]").textContent = 234 "Step " + (current + 1) + " of " + steps.length; 235 if (progressBar) progressBar.style.width = ((current + 1) / steps.length * 100) + "%"; 236 } 237 238 function go(idx) { 239 if (idx < 0 || idx >= steps.length) return; 240 collect(); 241 steps.forEach(function (s, i) { s.hidden = i !== idx; }); 242 current = idx; 243 updateProgress(); 244 applyStepCopy(); 245 track("form_step", { 246 step_number: idx + 1, 247 step_name: steps[idx].dataset.step, 248 form_type: formType 249 }); 250 // Leaving step 1 is the site's real micro-conversion â it is the moment a 251 // visitor stops browsing. Only the ZIP's first three digits: that is a 252 // multi-county area, whereas a full ZIP alongside a first name and a 253 // timestamp is identifiable, and GA4 must stay PII-clean. 254 if (idx === 1 && !zipTracked) { 255 zipTracked = true; 256 track("zip_entered", { 257 postal_code_prefix: String(data.postal_code || "").slice(0, 3), 258 form_type: formType 259 }); 260 } 261 var focusTarget = steps[idx].querySelector("input:not([type=hidden]), button"); 262 // Only pull focus on forward navigation between steps, never on first 263 // paint â auto-focusing a ZIP field on load scroll-jacks mobile. 264 if (idx >
264 0 && focusTarget) focusTarget.focus({ preventScroll: true }); 265 // Measure the sticky chrome rather than guessing: the banner wraps to three 266 // lines on a phone and to one on desktop, so a fixed offset scrolls the top 267 // of the step underneath the header on exactly the devices that can least 268 // afford it. 269 var banner = document.querySelector(".not-gov-banner"); 270 var header = document.querySelector(".site-header"); 271 var chrome = (banner ? banner.offsetHeight : 0) + (header ? header.offsetHeight : 0); 272 var top = form.getBoundingClientRect().top + window.scrollY - chrome - 24; 273 if (idx > 0) window.scrollTo({ top: top, behavior: "smooth" }); 274 } 275 276 /* Step-specific copy that depends on answers or on agent availability. */ 277 function applyStepCopy() { 278 var zipEcho = form.querySelectorAll("[data-zip-echo]"); 279 zipEcho.forEach(function (el) { el.textContent = data.postal_code || ""; }); 280 281 // "who calls you and when" â the single highest-value sentence on the 282 // form, and it has to be true out of hours as well as in. 283 var when = form.querySelector("[data-callback-promise]"); 284 if (when) { 285 var available = window.HEC_AGENTS_AVAILABLE; 286 var num = window.HEC_PHONE || "(844) 671-5373"; 287 // HEC_NEXT_OPEN is phone-pool.js's own day-parting string ("Agents back 288 // Mon at 8am CT"), so the promise here can't drift out of sync with the 289 // hours the rest of the site advertises. Falling back to a vague 290 // sentence is deliberate â a made-up specific time is worse than none. 291 var next = String(window.HEC_NEXT_OPEN || ""); 292 var back = /^Agents back/.test(next) 293 ? next.replace(/^Agents back/, "Agents are back") 294 : "Agents are back in the morning"; 295 when.innerHTML = available 296 ? "A licensed agent will call you from <strong>" + num + 297 "</strong> â usually within 5 minutes. Save it so you know it's us." 298 : back + ", and you'll be first in the queue. Your call will come from <strong>" + 299 num + "</strong> â save it so you know it's us."; 300 } 301 } 302 303 // --- submit ----------------------------------------------------------- 304 function payload() { 305 collect(); 306 var out = {}; 307 Object.keys(data).forEach(function (k) { 308 if (data[k] !== "" && data[k] !== undefined) out[k] = data[k]; 309 }); 310 out.form_type = formType; 311 out.consent = data.consent ? "yes" : ""; 312 313 // TrustedForm writes its cert URL into these hidden inputs after the page 314 // loads, so read them at submit time rather than from `data`. 315 ["xxTrustedFormCertUrl", "xxTrustedFormPingUrl"].forEach(function (n) { 316 var el = form.querySelector('[name="' + n + '"]'); 317 if (el && el.value) out[n] = el.value; 318 }); 319 var hp = form.querySelector('[name="company_website"]'); 320 out.company_website = hp ? hp.value : ""; 321 322 if (typeof window.hecAttribution === "function") { 323 var attr = window.hecAttribution(); 324 Object.keys(attr).forEach(function (k) { out[k] = attr[k]; }); 325 } 326 // Sent so that, once a `website_lead_id` custom field exists in HighLevel, 327 // an offline conversion uploaded weeks later can be matched back to the 328 // session that produced it â not just to a GCLID, which only paid Google 329 // clicks ever have. /api/lead ignores it until that field is created; 330 // until then it still de-duplicates the Ads conversion. 331 if (typeof window.hecLeadId === "function") out.lead_id = window.hecLeadId(); 332 return out; 333 } 334 335 async function submit() { 336 if (submitting) return; 337 var btn = form.querySelector("[data-submit]"); 338 var label = btn ? btn.textContent : ""; 339 submitting = true; 340 if (btn) { 341 // Fix the width first so the button doesn't collapse when the label 342 // shortens mid-request. 343 btn.style.minWidth = btn.offsetWidth + "px"; 344 btn.disabled = true; 345 // "Finding your plans" is a lie on the recruiting form, and a pending 346 // label that doesn't match what was clicked reads as the wrong form 347 // having been submitted. 348 btn.textContent = formType === "job" ? "Sending your applicationâ¦" : "Finding your plansâ¦"; 349 } 350 var formError = form.querySelector("[data-form-error]"); 351 if (formError) formError.hidden = true; 352 353 var body = payload();
354 try { 355 var res = await fetch(ENDPOINT, { 356 method: "POST", 357 // Accept is load-bearing: /api/lead 303-redirects requests that don't 358 // ask for JSON, so the no-JS native form POST lands on /thank-you. 359 headers: { "Content-Type": "application/json", Accept: "application/json" }, 360 body: JSON.stringify(body) 361 }); 362 var json = await res.json().catch(function () { return {}; }); 363 364 if (!res.ok || !json.ok) { 365 track("generate_lead_failed", { status: res.status, form_type: formType }); 366 if (formError) { 367 formError.textContent = json.error || 368 "We couldn't submit that. Please call us and we'll help right away."; 369 formError.hidden = false; 370 } 371 if (btn) { btn.disabled = false; btn.textContent = label; } 372 submitting = false; 373 return; 374 } 375 376 // The job form must not fire generate_lead: a recruiting application in 377 // the Ads conversion stream poisons smart bidding for consumer leads. 378 if (formType !== "job") { 379 // Enhanced Conversions: must be set before the conversion event, and 380 // must be normalised â body.phone is a display mask, not E.164. 381 if (typeof window.hecSetUserData === "function") { 382 window.hecSetUserData(body.email, body.phone); 383 } 384 track("generate_lead", { 385 form_type: formType, 386 // form_start already reports this, so without it here GA4 can say 387 // which page started a form but not which one finished it â 388 // leaving conversion rate by placement unanswerable. That is the 389 // whole reason the generator derives a distinct location per slug 390 // rather than hardcoding one. 391 form_location: form.dataset.formLocation || "page", 392 value: 10, 393 currency: "USD", 394 lead_id: body.lead_id || "", 395 coverage_type: body.coverage_type || "", 396 insured: body.are_you_currently_insured || "", 397 state: body.state || "" 398 }); 399 } 400 401 // Hand the thank-you page what it needs to personalise, then clear the 402 // draft so a back-navigation doesn't resubmit stale answers. 403 try { 404 sessionStorage.setItem("hec_lead", JSON.stringify({ 405 first_name: body.first_name || "", 406 postal_code: body.postal_code || "", 407 form_type: formType 408 })); 409 sessionStorage.removeItem(store); 410 } catch (e) { /* private mode */ } 411 412 window.location.href = json.redirect || 413 (formType === "job" ? "/thank-you-application" : "/thank-you"); 414 } catch (err) { 415 track("generate_lead_failed", { status: 0, form_type: formType }); 416 if (formError) { 417 formError.textContent = 418 "We couldn't reach our system. Please call us and we'll help right away."; 419 formError.hidden = false; 420 } 421 if (btn) { btn.disabled = false; btn.textContent = label; } 422 submitting = false; 423 } 424 } 425 426 // --- wiring ----------------------------------------------------------- 427 restore(); 428 steps.forEach(function (s, i) { s.hidden = i !== 0; }); 429 updateProgress(); 430 applyStepCopy(); 431 window.addEventListener("hec:phone-applied", applyStepCopy); 432 433 form.addEventListener("submit", function (e) { 434 e.preventDefault(); 435 if (current < steps.length - 1) { 436 if (validateStep(current)) go(current + 1); 437 } else if (validateStep(current)) { 438 submit(); 439 } 440 }); 441 442 form.addEventListener("click", function (e) { 443 var next = e.target.closest("[data-next]"); 444 if (next) { e.preventDefault(); if (validateStep(current)) go(current + 1); return; } 445 var back = e.target.closest("[data-back]"); 446 if (back) { e.preventDefault(); go(current - 1); } 447 }); 448 449 form.addEventListener("input", function (e) { 450 var el = e.target; 451 if (!el.name) return; 452 453 if (!startTracked) { 454 startTracked = true; 455 track("form_start", { 456 form_type: formType, 457 form_location: form.dataset.formLocation || "page", 458 page_path: location.pathname 459 }); 460 } 461 462 if (el.name === "phone") { 463 var pos = el.selectionStart === el.value.length; 464 el.value = phoneMask(el.value); 465 if (pos) el.setSelectionRange(el.value.length, el.value.length); 466 } 467 if (el.name === "postal_code") { 468 el.value = el.value.replace(/\D/g, "").slice(0, 5); 469 var cta = form.querySelector("[data-zip-cta]"); 470 // Echoing the ZIP back in the button turns an abstract action into a 471 // confirmation of something the user just did. 472 if (cta) { 473 cta.textContent = /^\d{5}
473$/.test(el.value) 474 ? "Find plans in " + el.value + " â" 475 : "Find plans in my area â"; 476 } 477 } 478 // Re-validate live only once a field has already failed, so we never 479 // show an error while someone is still typing their first attempt. 480 if (el.classList.contains("has-error")) validate(el); 481 saveSoon(); 482 }); 483 484 // Selects don't reliably emit `input` in every browser, and radio cards 485 // emit nothing else. 486 form.addEventListener("change", function (e) { 487 if (!e.target.name) return; 488 saveSoon(); 489 // Clear a group's "please choose" error the moment it's answered, rather 490 // than making the user press Continue again to see it disappear. 491 if (e.target.type === "radio") clearGroupError(e.target.name); 492 }); 493 494 form.addEventListener("blur", function (e) { 495 var el = e.target; 496 if (!el.name || !V[el.name]) return; 497 validate(el); 498 if (el.name === "email" && el.value.indexOf("@") > -1) suggestDomain(el); 499 }, true); 500 501 function suggestDomain(el) { 502 var host = el.value.split("@")[1] || ""; 503 var fix = DOMAIN_TYPOS[host.toLowerCase()]; 504 var slot = form.querySelector("[data-email-suggest]"); 505 if (!slot) return; 506 if (!fix) { slot.hidden = true; return; } 507 var fixed = el.value.split("@")[0] + "@" + fix; 508 slot.innerHTML = 'Did you mean <button type="button" class="link-btn">' + fixed + "</button>?"; 509 slot.hidden = false; 510 slot.querySelector("button").addEventListener("click", function () { 511 el.value = fixed; 512 slot.hidden = true; 513 validate(el); 514 }); 515 } 516 517 // Radio cards no longer auto-advance. Selecting a card used to jump to the 518 // next step as soon as every group was answered â which, once answers were 519 // restored from a prior session (back-navigation, reload), meant the first 520 // tap on any card fired the jump before the visitor could review or change 521 // their choice. Step 2 advances only when they press Continue (data-next), 522 // handled by the click listener above. 523 } 524 525 /* Personalise the thank-you page from what we just submitted. Read from 526 sessionStorage rather than a query string so a first name and ZIP never end 527 up in the URL, the referrer header, or an analytics report. Both slots have 528 working default copy in the HTML, so a direct visit or a cleared session 529 still reads correctly. */ 530 function hydrateThanks() { 531 var nameEl = document.querySelector("[data-thanks-name]"); 532 var zipEl = document.querySelector("[data-thanks-zip]"); 533 if (!nameEl && !zipEl) return; 534 var lead; 535 try { lead = JSON.parse(sessionStorage.getItem("hec_lead") || "{}"); } catch (e) { lead = {}; } 536 if (nameEl && lead.first_name) nameEl.textContent = ", " + lead.first_name; 537 if (zipEl && lead.postal_code) zipEl.textContent = lead.postal_code; 538 539 /* Deliberately NOT a conversion. generate_lead already fired on the page 540 before this one, and firing again here would double-count every lead that 541 reloads or back-navigates onto a cached thank-you page â which poisons 542 smart bidding far more expensively than under-counting would. This is a 543 plain GA4 event for funnel completeness only. */ 544 track("thank_you_view", { 545 form_type: lead.form_type || "", 546 lead_id: (typeof window.hecLeadId === "function") ? window.hecLeadId() : "", 547 agents_available: !!window.HEC_AGENTS_AVAILABLE 548 }); 549 } 550 551 function init() { 552 document.querySelectorAll("[data-lead-form]").forEach(initForm); 553 hydrateThanks(); 554 555 /* Click-to-call is the primary conversion on 124 of the pages and was 556 entirely untracked. Delegated so it also covers the header/footer slots 557 site.js injects after this runs. */ 558 document.addEventListener("click", function (e) { 559 var phone = e.target.closest("[data-phone-slot]"); 560 if (phone) { 561 track("phone_call_click", { 562 phone_number: phone.dataset.poolNumber || "", 563 placement: phone.dataset.placement || "unknown", 564 page_path: location.pathname, 565 agents_available: !!window.HEC_AGENTS_AVAILABLE 566 }); 567 return; 568 } 569 var cb = e.target.closest("[data-callback-slot]"); 570 if (cb) { 571 track("callback_request_click", { 572 placement: cb.dataset.placement || "unknown", 573 page_path: location.pathname 574 }); 575 } 576 }); 577 578 // Quantifies the out-of-hours hole rather than leaving it as an assumption. 579 window.addEventListener("hec:phone-applied", function (e) { 580 if (e.detail && e.detail.available === false) { 581 track("agents_unavailable_view", { page_path: location.pathname }); 582 } 583 }, { once: true }); 584 } 585 586 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); 587 else init(); 588})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.