PageSourceSearch

https://billmob.com/assets/app.js?v=6

js billmob.com collected 2026-10-03 20:07:29 UTC 12,541 bytes, 268 lines download raw bytes

1/* billmob — public site interactions. Vanilla JS, no deps.
2   Fail-safe scroll reveal · mobile nav · sticky header ·
3   smooth in-page scroll · sticky conversion bar · cookie consent + lazy GA ·
4   callback form · cookieless first-party page-view beacon. */
5(function () {
6  'use strict';
7  var docEl = document.documentElement;
8  if (docEl.classList) { docEl.classList.add('js'); }
9  if (window.__revealFailsafe) { clearTimeout(window.__revealFailsafe); }
10
11  var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
12
13  function ready(fn) {
14    if (document.readyState !== 'loading') { fn(); }
15    else { document.addEventListener('DOMContentLoaded', fn); }
16  }
17
18  /* ---------- Google Analytics: lazy-load, consent-aware ------------------- */
19  var GA = window.__GA_ID;
20  function gaLoad() {
21    if (!GA || window.__gaLoaded) return;
22    window.__gaLoaded = true;
23    var s = document.createElement('script');
24    s.async = true;
25    s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA;
26    document.head.appendChild(s);
27  }
28  function gaDisable() {
29    if (GA) window['ga-disable-' + GA] = true;
30    // best-effort clear of any existing GA cookies
31    document.cookie.split(';').forEach(function (c) {
32      var n = c.split('=')[0].trim();
33      if (n.indexOf('_ga') === 0) {
34        document.cookie = n + '=; Max-Age=0; path=/';
35        document.cookie = n + '=; Max-Age=0; path=/; domain=.' + location.hostname;
36      }
37    });
38  }
39  var consent = null;
40  try { consent = localStorage.getItem('bm-consent'); } catch (e) {}
41  if (consent === 'declined') { gaDisable(); }
42  else {
43    // implied consent (easy opt-out below): load GA lazily so it never blocks paint
44    var armed = false;
45    var arm = function () {
46      if (armed) return; armed = true;
47      gaLoad();
48      ['pointerdown', 'keydown', 'scroll', 'touchstart'].forEach(function (ev) {
49        window.removeEventListener(ev, arm, { passive: true });
50      });
51    };
52    ['pointerdown', 'keydown', 'scroll', 'touchstart'].forEach(function (ev) {
53      window.addEventListener(ev, arm, { passive: true });
54    });
55    if ('requestIdleCallback' in window) { requestIdleCallback(arm, { timeout: 5000 }); }
56    else { setTimeout(arm, 4000); }
57  }
58
59  /* ---------- cookieless first-party page-view beacon --------------------- */
60  (function beacon() {
61    try {
62      if (/\/admin(\/|$)/.test(location.pathname)) return; // never track the admin panel
63      var p = new URLSearchParams(location.search);
64      var body = new URLSearchParams();
65      body.set('p', location.pathname);
66      body.set('r', document.referrer || '');
67      ['utm_source', 'utm_medium', 'utm_campaign'].forEach(function (k) {
68        if (p.get(k)) body.set(k, p.get(k));
69      });
70      var send = function () {
71        if (navigator.sendBeacon) { navigator.sendBeacon('/hit.php', body); }
72        else { fetch('/hit.php', { method: 'POST', body: body, keepalive: true }).catch(function () {}); }
73      };
74      if ('requestIdleCallback' in window) { requestIdleCallback(send, { timeout: 3000 }); }
75      else { setTimeout(send, 300); }
76    } catch (e) {}
77  })();
78
79  ready(function () {
80    /* ---- mobile nav drawer ---- */
81    var toggle = document.querySelector('.nav-toggle');
82    var links = document.querySelector('.site-links');
83    if (toggle && links) {
84      var setOpen = function (open) {
85        links.classList.toggle('open', open);
86        document.body.classList.toggle('nav-open', open);
87        toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
88      };
89      toggle.addEventListener('click', function () { setOpen(!links.classList.contains('open')); });
90      links.querySelectorAll('a').forEach(function (a) { a.addEventListener('click', function () { setOpen(false); }); });
91      window.addEventListener('keydown', function (e) { if (e.key === 'Escape') setOpen(false); });
92    }
93
94    /* ---- sticky header shadow on scroll ---- */
95    var header = document.querySelector('.site-header');
96    if (header) {
97      var onScroll = function () { header.classList.toggle('scrolled', window.scrollY > 8); };
98      onScroll();
99      window.addEventListener('scroll', onScroll, { passive: true });
100    }
101
102    /* ---- reveal-on-scroll ---- */
103    var reveals = document.querySelectorAll('.reveal');
104    if (reveals.length && 'IntersectionObserver' in window && !reduced) {
105      var io = new IntersectionObserver(function (entries) {
106        entries.forEach(function (entry) {
107          if (entry.isIntersecting) { entry.target.classList.add('in'); io.unobserve(entry.target); }
108        });
109      }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
110      reveals.forEach(function (el) { io.observe(el); });
111    } else {
112      reveals.forEach(function (el) { el.classList.add('in'); });
113    }
114
115    /* ---- smooth in-page scroll for [data-scroll] and hash links ---- */
116    document.querySelectorAll('a[href^="#"]:not(.skip-link), a[data-scroll]').forEach(function (a) {
117      a.addEventListener('click', function (e) {
118        var id = (a.getAttribute('href') || '').replace(/^#/, '');
119        var target = id && document.getElementById(id);
120        if (target) {
121          e.preventDefault();
122          target.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'start' });
123          var f = target.querySelector('input, textarea');
124          if (f) setTimeout(function () { f.focus({ preventScroll: true }); }, reduced ? 0 : 500);
125        } else if (id === 'callback') {
126          // no callback block on this page (e.g. a legal page) — send them to Contact
127          e.preventDefault();
128          location.href = '/contact';
129        }
130      });
131    });
132
133    /* ---- the live bill: a 2.8s tick drives both the trade on the receipt
134            (dine-in ↔ counter sale) and the connection status. Exactly one of
135            the two changes per tick, so the receipt never reads as noise, and
136            every trade is shown both online and offline.
137            The FIRST paint is the counter sale, offline — set in index.php so it
138            shows before this script runs (and for no-JS / reduced-motion). ---- */
139    var billStatus = document.getElementById('billStatus');
140    var billCard = document.getElementById('billCard');
141    if ((billStatus || billCard) && !reduced) {
142      // Begin at step 2 (counter sale · offline) to match the markup's first paint,
143      // then advance: counter/off → dine-in/off → dine-in/on → counter/on → …
144      var billStep = 2;
145      setInterval(function () {
146        billStep = (billStep + 1) % 4;
147        if (billCard) billCard.classList.toggle('is-b', billStep === 1 || billStep === 2);
148        if (billStatus) billStatus.classList.toggle('off', billStep === 2 || billStep === 3);
149      }, 2800);
150    }
151    /* Under reduced motion (or no JS) the markup already paints the counter sale,
152       offline, so there is nothing to set here. */
153
154    /* ---- sticky conversion bar ---- */
155    var sticky = document.getElementById('stickyCta');
156    if (sticky) {
157      var dismissed = false;
158      try { dismissed = sessionStorage.getItem('bm-sticky') === 'off'; } catch (e) {}
159      var footer = document.querySelector('.site-footer');
160      var stOnScroll = function () {
161        if (dismissed) return;
162        var nearFooter = footer && (footer.getBoundingClientRect().top < window.innerHeight + 40);
163        var past = window.scrollY > window.innerHeight * 0.9;
164        sticky.classList.toggle('show', past && !nearFooter);
165        sticky.setAttribute('aria-hidden', (past && !nearFooter) ? 'false' : 'true');
166      };
167      stOnScroll(); // a reload or back-nav restores scroll without firing an event
168      window.addEventListener('scroll', stOnScroll, { passive: true });
169      var closeBtn = document.getElementById('stickyClose');
170      if (closeBtn) closeBtn.addEventListener('click', function () {
171        /* blur first: hiding an ancestor of document.activeElement is the case
172           ARIA forbids, and it drops the keyboard user back to the document top. */
173        closeBtn.blur();
174        dismissed = true; sticky.classList.remove('show');
175        sticky.setAttribute('aria-hidden', 'true');
176        try { sessionStorage.setItem('bm-sticky', 'off'); } catch (e) {}
177      });
178    }
179
180    /* ---- cookie notice ---- */
181    var notice = document.getElementById('cookieNotice');
182    if (notice && consent !== 'accepted' && consent !== 'declined') {
183      setTimeout(function () { notice.classList.add('show'); }, 900);
184      var save = function (v) {
185        try { localStorage.setItem('bm-consent', v); } catch (e) {}
186        notice.classList.remove('show');
187      };
188      var acc = document.getElementById('cookieAccept');
189      var dec = document.getElementById('cookieDecline');
190      if (acc) acc.addEventListener('click', function () { save('accepted'); gaLoad(); });
191      if (dec) dec.addEventListener('click', function () { save('declined'); gaDisable(); });
192    }
193
194    /* ---- callback form: client validation + inline AJAX submit ---- */
195    var form = document.getElementById('callbackForm');
196    if (form && form.tagName === 'FORM') {
197      var pageF = document.getElementById('cb-page');
198      var utmF = document.getElementById('cb-utm');
199      if (pageF) pageF.value = location.pathname;
200      if (utmF) utmF.value = location.search.replace(/^\?/, '').slice(0, 300);
201
202      var statusEl = document.getElementById('cbStatus');
202 // role=status, aria-live=polite
203      var nameF = form.querySelector('[name=name]');
204      var phoneF = form.querySelector('[name=phone]');
205
206      /* Known-field error (client validation): announce, flag the field, focus it. */
207      var showFieldError = function (input, msg) {
208        if (statusEl) statusEl.textContent = msg;
209        input.setAttribute('aria-invalid', 'true');
210        input.focus();
211      };
212      /* Whole-form error (server-side: throttle, name-length, internal error).
213         The server returns one message and no field, so don't blame a field —
214         just announce it and leave focus on the re-enabled submit button. */
215      var showFormError = function (msg) {
216        if (statusEl) statusEl.textContent = msg;
217      };
218      var clearError = function () {
219        if (statusEl) statusEl.textContent = '';
220        nameF.removeAttribute('aria-invalid');
221        phoneF.removeAttribute('aria-invalid');
222      };
223
224      form.addEventListener('submit', function (e) {
225        e.preventDefault();
226        clearError();
227
228        if (!nameF.value.trim()) { showFieldError(nameF, 'Please enter your name.'); return; }
229        var digits = phoneF.value.replace(/\D+/g, '');
230        if (!phoneF.value.trim() || digits.length < 7) {
231          showFieldError(phoneF, 'Please enter a valid phone number so we can call you back.');
232          return;
233        }
234
235        var btn = form.querySelector('button[type=submit]');
236        var origText = btn.textContent;
237        btn.disabled = true; btn.textContent = 'Sending…';
238        fetch(form.action, { method: 'POST', body: new FormData(form), headers: { 'X-Requested-With': 'fetch' } })
239          .then(function (r) {
240            /* 200 → success JSON. 422/429 → the server's validation/rate reply,
241               which we surface inline (NOT a network failure). Anything else
242               falls through to the full-POST fallback so the lead still lands. */
243            return r.json().catch(function () { return null; }).then(function (j) {
244              if (r.ok && j && j.ok !== false) return j;
245              if (j && j.error) throw { soft: true, msg: j.error };
246              if (!r.ok) throw { soft: false };
247              return j;
248            });
249          })
250          .then(function () {
251            form.innerHTML =
252              '<div class="form-ok">' +
253              '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' +
254              '<b>Thanks — we\'ve got it.</b>' +
255              '<span>We\'ll call you back shortly.</span></div>';
256          })
257          .catch(function (err) {
258            btn.disabled = false; btn.textContent = origText;
259            if (err && err.soft) { showFormError(err.msg); return; }
260            /* network/down: fall back to a full POST so a dropped connection
261               doesn't lose the lead */
262            form.action += (form.action.indexOf('?') < 0 ? '?' : '&') + 'nojs=1';
263            form.submit();
264          });
265      });
266    }
267  });
268})();

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.