1/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 CLOSING CTA BAND (W-091) â progressive-enhancement behavior. 3 4 The band is fully functional server-rendered: copy + trust + native <a> 5 actions (or a native <form> capture) work with this file absent. cta-band.js 6 only ATTACHES where the band actually needs runtime, each leg guarded so a 7 failure degrades to the static markup: 8 9 1. VIDEO BG â plays the ambient <video> in view (IntersectionObserver), 10 wires the REQUIRED visible pause control (aria-pressed + visible label 11 stay in sync, WCAG 2.2.2 / 2.5.3), NEVER autoplays under 12 prefers-reduced-motion (poster shows, control still works), pauses 13 offscreen. A user pause always wins over the IO resume. 14 15 2. ASYNC CAPTURE â only when the capture <form> carries 16 data-hzo-cta-band-async (a same-site /wp-json/ route). Synchronous 17 client validation first (invalid -> aria-invalid + focus, no network), 18 then submit-lock + .is-loading + aria-busy set <100ms BEFORE fetch, 19 AbortController 8s timeout, navigator.onLine gate. Success swaps the 20 input-group in place for a confirmation (no navigation), snapshot -> 21 restore focus to the confirmation, and announces via hzo.announce() 22 (body-portaled OUTSIDE the swapped fragment, G30). Every .catch 23 reconciles: re-enable, PRESERVE entered values, specific recoverable 24 message (offline vs timeout vs server). Without the async marker the 25 form is a normal POST (states belong to the destination â G26). 26 27 3. DESKTOP CLICK-TO-COPY â a kind=call action's tel: no-ops on desktop 28 (§13 dead-desktop-CTA): on a fine pointer with clipboard support, the 29 click copies the number + announces "Copied", instead of a dead tel:. 30 Coarse pointers keep the native tel: dial. The number is also visible 31 selectable text regardless. 32 33 4. MOBILE STICKY HANDOFF â mobile="sticky" clones the actions into a 34 .hzo-sticky-bar and hands reveal to the always-loaded sticky-cta.js 35 (data-hzo-sticky-after = the band). Clones tag location:'sticky' so a 36 docked-copy tap is attributed without double-counting the conversion. 37 38 5. MEASUREMENT â via the dataLayer (never gtag directly): phone_call 39 (kind=call), select_content (kind=book|quote|link), generate_lead 40 (async capture success). No PII in the payloads. 41 42 The band CONSUMES hzo:reveal (via .hzo-reveal / W-020) but dispatches no new 43 hzo:* event (an event with zero consumers is a dead event). Idempotent 44 (data-hzo-cta-band-ready). Namespaced on window.hzo.ctaBand. 45 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 46(function () { 47 'use strict'; 48 49 window.hzo = window.hzo || {}; 50 51 var reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); 52 53 function __(s) { 54 return (window.wp && wp.i18n && wp.i18n.__) ? wp.i18n.__(s, 'hozio-studio') : s; 55 } 56 function sprintf(fmt, a) { 57 return (window.wp && wp.i18n && wp.i18n.sprintf) ? wp.i18n.sprintf(fmt, a) : fmt.replace('%s', a); 58 } 59 function announce(msg, level) { 60 if (window.hzo && typeof window.hzo.announce === 'function') { 61 window.hzo.announce(msg, level || 'polite'); 62 } 63 } 64 function pushDL(obj) { 65 window.dataLayer = window.dataLayer || []; 66 window.dataLayer.push(obj); 67 } 68 69 /* âââ 1. Video background ââââââââââââââââââââââââââââââââââââââââââââââ */ 70 function setupVideo(band) { 71 var video = band.querySelector('[data-hzo-cta-band-video]'); 72 var toggle = band.querySelector('[data-hzo-cta-band-video-toggle]'); 73 if (!video) { return; } 74 video.muted = true; 75 76 var userPaused = false;
77 var label = toggle ? toggle.querySelector('.hzo-cta-band__video-toggle-label') : null; 78 var playText = __('Play'), pauseText = __('Pause'); 79 var playAria = __('Play background video'), pauseAria = __('Pause background video'); 80 81 function sync() { 82 if (!toggle) { return; } 83 var paused = video.paused; 84 toggle.setAttribute('aria-pressed', paused ? 'true' : 'false'); 85 toggle.setAttribute('aria-label', paused ? playAria : pauseAria); 86 if (label) { label.textContent = paused ? playText : pauseText; } 87 } 88 function tryPlay() { 89 var p = video.play(); 90 if (p && typeof p.catch === 'function') { p.catch(function () {}); } 91 } 92 93 if (reduce) { 94 // Never autostart under reduced motion â poster shows, pause reflects 95 // paused; an explicit Play is the user's own choice (not vetoed). 96 video.autoplay = false; 97 video.removeAttribute('autoplay'); 98 try { video.pause(); } catch (e) {} 99 } else { 100 tryPlay(); 101 } 102 103 if (toggle) { 104 toggle.addEventListener('click', function () { 105 if (video.paused) { userPaused = false; tryPlay(); } 106 else { userPaused = true; video.pause(); } 107 }); 108 video.addEventListener('play', sync); 109 video.addEventListener('pause', sync); 110 sync(); 111 } 112 113 if ('IntersectionObserver' in window) { 114 new IntersectionObserver(function (entries) { 115 var visible = entries[entries.length - 1].isIntersecting; 116 if (!visible) { 117 if (!video.paused) { video.pause(); } 118 } else if (!userPaused && !reduce) { 119 tryPlay(); 120 } 121 }).observe(band); 122 } 123 } 124 125 /* âââ 2. Async inline capture ââââââââââââââââââââââââââââââââââââââââââ */ 126 function validEmail(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); } 127 function validPhone(v) { return v.replace(/\D/g, '').length >= 10; } 128 129 function setupCapture(band) { 130 var form = band.querySelector('form[data-hzo-cta-band-async]'); 131 if (!form) { return; } // non-async capture is a plain native POST â nothing to do 132 var url = form.getAttribute('data-hzo-cta-band-async'); 133 var method = form.getAttribute('data-hzo-cta-band-method') || 'email'; 134 var input = form.querySelector('input'); 135 var submit = form.querySelector('.hzo-cta-band__submit'); 136 var status = form.querySelector('[data-hzo-cta-band-status]'); 137 if (!url || !input || !submit) { return; } 138 139 function showStatus(msg, isError) { 140 if (!status) { return; } 141 status.textContent = msg; 142 status.hidden = false; 143 status.classList.toggle('is-error', !!isError); 144 } 145 function lock() { 146 submit.classList.add('is-loading'); 147 submit.setAttribute('aria-busy', 'true'); 148 submit.disabled = true; // holds focus through the lock (no handoff) 149 } 150 function unlock() { 151 submit.classList.remove('is-loading'); 152 submit.setAttribute('aria-busy', 'false'); 153 submit.disabled = false; 154 } 155 156 form.addEventListener('submit', function (e) { 157 e.preventDefault(); 158 if (submit.disabled) { return; } // spam guard: one in-flight request 159 160 var value = input.value.replace(/^\s+|\s+$/g, ''); 161 var ok = (method === 'phone') ? validPhone(value) : validEmail(value); 162 if (!ok) { 163 input.setAttribute('aria-invalid', 'true'); 164 var vmsg = (method === 'phone') 165 ? __('Please enter a valid phone number.') 166 : __('Please enter a valid email address.'); 167 showStatus(vmsg, true); 168 announce(vmsg, 'assertive'); 169 input.focus(); 170 return; 171 } 172 input.removeAttribute('aria-invalid'); 173 174 if (window.navigator && navigator.onLine === false) { 175 var offmsg = __('You appear to be offline â reconnect and try again.'); 176 showStatus(offmsg, true); 177 announce(offmsg, 'assertive'); 178 return; 179 } 180 181 lock(); 182 status && (status.hidden = true); 183 184 var controller = ('AbortController' in window) ? new AbortController() : null; 185 var timedOut = false;
186 var timer = window.setTimeout(function () { 187 timedOut = true; 188 if (controller) { controller.abort(); } 189 }, 8000); 190 191 var payload = {}; 192 payload[method] = value; 193 194 var opts = { 195 method: 'POST', 196 headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, 197 credentials: 'same-origin', 198 body: JSON.stringify(payload) 199 }; 200 if (controller) { opts.signal = controller.signal; } 201 if (window.wpApiSettings && wpApiSettings.nonce) { 202 opts.headers['X-WP-Nonce'] = wpApiSettings.nonce; 203 } 204 205 window.fetch(url, opts).then(function (res) { 206 if (!res.ok) { throw new Error('http ' + res.status); } 207 return res; 208 }).then(function () { 209 window.clearTimeout(timer); 210 complete(); 211 }).catch(function () { 212 window.clearTimeout(timer); 213 unlock(); // re-enable; PRESERVE entered value (never cleared) 214 var msg = timedOut 215 ? __('That took too long â please try again.') 216 : ((window.navigator && navigator.onLine === false) 217 ? __('You appear to be offline â reconnect and try again.') 218 : __('Couldnât send that â check your connection and try again.')); 219 showStatus(msg, true); 220 announce(msg, 'assertive'); 221 }); 222 }); 223 224 function complete() { 225 var group = form.querySelector('.hzo-input-group'); 226 var msg = __('Thanks â weâll be in touch shortly.'); 227 var confirm = document.createElement('p'); 228 confirm.className = 'hzo-cta-band__confirm'; 229 confirm.setAttribute('tabindex', '-1'); 230 confirm.textContent = msg; 231 if (group) { form.replaceChild(confirm, group); } 232 else { form.appendChild(confirm); } 233 if (status) { status.hidden = true; } 234 // Snapshot -> restore focus into the confirmation (PRESERVE-2). The 235 // announce region is body-portaled OUTSIDE this fragment (G30). 236 try { confirm.focus(); } catch (e) {} 237 announce(msg, 'polite'); 238 pushDL({ event: 'generate_lead', form: 'cta_band', method: method }); 239 } 240 } 241 242 /* âââ 3. Desktop click-to-copy for kind=call âââââââââââââââââââââââââââ */ 243 var finePointer = !!(window.matchMedia && window.matchMedia('(pointer: fine)').matches); 244 245 function bindCopy(band) { 246 if (!finePointer || !(navigator.clipboard && navigator.clipboard.writeText)) { return; } 247 var calls = band.querySelectorAll('a[data-hzo-cta-kind="call"][data-hzo-cta-copy]'); 248 for (var i = 0; i < calls.length; i++) { 249 (function (a) { 250 a.addEventListener('click', function (e) { 251 // Desktop tel: is a dead click â copy the number instead. 252 e.preventDefault(); 253 var num = a.getAttribute('data-hzo-cta-copy') || ''; 254 navigator.clipboard.writeText(num).then(function () { 255 var msg = sprintf(__('Copied %s'), num); 256 announce(msg, 'polite'); 257 if (window.hzo && typeof window.hzo.toast === 'function') { 258 window.hzo.toast(msg, { type: 'success' }); 259 } 260 }).catch(function () {}); 261 }); 262 })(calls[i]); 263 } 264 } 265 266 /* âââ 4. Mobile sticky handoff âââââââââââââââââââââââââââââââââââââââââ */ 267 function setupSticky(band) { 268 if (band.getAttribute('data-hzo-cta-band-mobile') !== 'sticky') { return; } 269 var actions = band.querySelector('.hzo-cta-band__actions'); 270 if (!actions) { return; } 271 if (document.querySelector('[data-hzo-sticky-bar][data-hzo-cta-band-clone
271="' + band.id + '"]')) { return; } 272 273 var bar = document.createElement('div'); 274 bar.className = 'hzo-sticky-bar'; 275 bar.setAttribute('data-hzo-sticky-bar', ''); 276 bar.setAttribute('data-hzo-cta-band-clone', band.id); 277 bar.setAttribute('data-hzo-sticky-after', '#' + band.id); 278 bar.setAttribute('data-hzo-sticky-cta-until', '#' + band.id); 279 bar.setAttribute('role', 'region'); 280 bar.setAttribute('aria-label', __('Quick contact actions')); 281 282 var links = actions.querySelectorAll('.hzo-cta-band__action'); 283 for (var i = 0; i < links.length; i++) { 284 var clone = links[i].cloneNode(true); 285 clone.setAttribute('data-hzo-cta-location', 'sticky'); // dedupe attribution 286 // The clones ride a LIGHT sticky bar, not the dark band â drop the 287 // on-dark button styling and the large size so they read correctly. 288 clone.classList.remove('hzo-btn--on-dark', 'hzo-btn--lg'); 289 bar.appendChild(clone); 290 } 291 document.body.appendChild(bar); 292 293 // Hand reveal to the always-loaded sticky-cta.js (guarded â never a hard dep). 294 if (window.hzo && window.hzo.stickyCta && typeof window.hzo.stickyCta.init === 'function') { 295 window.hzo.stickyCta.init(document); 296 } 297 } 298 299 /* âââ 5. Measurement (delegated, bound once) âââââââââââââââââââââââââââ */ 300 function bindMeasurement() { 301 if (window.hzo.__ctaBandMeasureBound) { return; } 302 window.hzo.__ctaBandMeasureBound = true; 303 document.addEventListener('click', function (e) { 304 var a = e.target && e.target.closest ? e.target.closest('.hzo-cta-band__action') : null; 305 if (!a) { return; } 306 var kind = a.getAttribute('data-hzo-cta-kind') || 'link'; 307 var location = a.getAttribute('data-hzo-cta-location') || 'band'; 308 if (kind === 'call') { 309 pushDL({ event: 'phone_call', source: 'cta_band', location: location }); 310 } else { 311 pushDL({ event: 'select_content', content_type: 'cta_band', item_id: kind, location: location }); 312 } 313 }, true); 314 } 315 316 /* âââ Init (idempotent) ââââââââââââââââââââââââââââââââââââââââââââââââ */ 317 function setup(band) { 318 if (!band || band.getAttribute('data-hzo-cta-band-ready') === 'true') { return; } 319 band.setAttribute('data-hzo-cta-band-ready', 'true'); 320 if (!band.id) { band.id = 'hzo-cta-band-' + Math.random().toString(36).slice(2, 10); } 321 setupVideo(band); 322 setupCapture(band); 323 bindCopy(band); 324 setupSticky(band); 325 } 326 327 function init(scope) { 328 var bands = (scope || document).querySelectorAll('[data-hzo-cta-band]'); 329 for (var i = 0; i < bands.length; i++) { setup(bands[i]); } 330 bindMeasurement(); 331 } 332 333 if (document.readyState === 'loading') { 334 document.addEventListener('DOMContentLoaded', function () { init(); }); 335 } else { 336 init(); 337 } 338 339 // Shared engine namespace â never a new top-level global. 340 window.hzo.ctaBand = { init: init, setup: setup }; 341})();
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.