PageSourceSearch

https://healthenrollcenter.com/assets/form.js?v=d3492336

js healthenrollcenter.com collected 2026-10-07 02:48:15 UTC 25,379 bytes, 588 lines download raw bytes

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.