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.