1/** 2 * Spin 360 â global override-proof injector. 3 * 4 * Listing: finds vehicle cards by their /vehicles/{id} link (a contract that 5 * survives any child-theme markup), asks the server which have a 360 spin, and 6 * injects a badge that opens the spin in a modal. Re-runs when AJAX 7 * filtering/pagination swaps in new cards. 8 * 9 * VDP: the spin src is resolved server-side and passed via window.SPIN360; a 10 * floating button opens the same modal. 11 * 12 * Idempotent: if the (non-overridden) template already rendered a 360 badge or 13 * spin slide, this layer defers and injects nothing â so no duplicates. 14 */ 15(function () { 16 'use strict'; 17 18 var CFG = window.SPIN360 || {}; 19 if (!CFG.ajaxUrl) { 20 return; 21 } 22 23 var TRIGGER = 'spin360-global-trigger'; 24 var lookedUp = {}; // client-side cache: vehicle id -> spin hash | null 25 var modal, iframe; 26 27 function ready(fn) { 28 if (document.readyState !== 'loading') { 29 fn(); 30 } else { 31 document.addEventListener('DOMContentLoaded', fn); 32 } 33 } 34 35 function getModal() { 36 if (!modal) { 37 modal = document.getElementById('adSpin360Modal'); 38 iframe = modal && modal.querySelector('.spin360-iframe'); 39 } 40 return modal; 41 } 42 43 function openModal(src) { 44 var m = getModal(); 45 if (!m || !iframe) { 46 return; 47 } 48 iframe.src = src; 49 m.classList.add('is-open'); 50 m.setAttribute('aria-hidden', 'false'); 51 document.body.style.overflow = 'hidden'; 52 } 53 54 function closeModal() { 55 var m = getModal(); 56 if (!m) { 57 return; 58 } 59 m.classList.remove('is-open'); 60 m.setAttribute('aria-hidden', 'true'); 61 if (iframe) { 62 iframe.src = ''; 63 } 64 document.body.style.overflow = ''; 65 } 66 67 function vehicleIdFromHref(href) { 68 var match = /\/vehicles\/(\d+)/.exec(href || ''); 69 return match ? match[1] : null; 70 } 71 72 function cardContainer(anchor) { 73 return anchor.closest('.inventory-card, .listing-vehicles-card, article, li') || anchor; 74 } 75 76 function makeBadge(spinId) { 77 var btn = document.createElement('button'); 78 btn.type = 'button'; 79 btn.className = 'spin360-badge ' + TRIGGER; 80 btn.setAttribute('data-spin-id', spinId); 81 btn.setAttribute('aria-label', 'View 360'); 82 83 var img = document.createElement('img'); 84 img.src = CFG.iconUrl; 85 img.alt = '360'; 86 img.loading = 'lazy'; 87 btn.appendChild(img); 88 89 return btn; 90 } 91 92 function injectBadge(anchor, spinId) { 93 var card = cardContainer(anchor); 94 if (card.getAttribute('data-spin360-done')) { 95 return; 96 } 97 98 // Idempotent: a template-rendered badge already covers this card. 99 if (card.querySelector('.ad-360-icon-button, .spin360-badge')) { 100 card.setAttribute('data-spin360-done', '1'); 101 return; 102 } 103 104 // Only treat it as a real vehicle card if there's an image to overlay; 105 // skips plain text links to /vehicles/ (breadcrumbs, "back" links, etc). 106 var imageHost = anchor.querySelector('img') ? anchor : null; 107 if (!imageHost && !card.querySelector('img')) { 108 return; 109 } 110 111 var host = imageHost || card; 112 var hostStyle = window.getComputedStyle(host); 113 // Reproduce the original's block, relatively-positioned image container. 114 // In child markup the image is wrapped in an inline <a>, which gives an 115 // absolutely-positioned badge no proper containing box (breaks on mobile). 116 if (hostStyle.position === 'static') { 117 host.style.position = 'relative'; 118 } 119 if (hostStyle.display === 'inline') { 120 host.style.display = 'block'; 121 } 122 host.appendChild(makeBadge(spinId)); 123 card.setAttribute('data-spin360-done', '1'); 124 } 125 126 function collectAnchorsById() { 127 var byId = {}; 128 document.querySelectorAll('a[href*="/vehicles/"]').forEach(function (a) { 129 var id = vehicleIdFromHref(a.getAttribute('href')); 130 if (!id) { 131 return; 132 } 133 (byId[id] = byId[id] || []).push(a); 134 }); 135 return byId; 136 } 137 138 function applySpins(byId, spinMap) {
139 Object.keys(spinMap).forEach(function (id) { 140 var hash = spinMap[id]; 141 if (!hash || !byId[id]) { 142 return; 143 } 144 byId[id].forEach(function (a) { 145 injectBadge(a, hash); 146 }); 147 }); 148 } 149 150 function scanListing() { 151 var byId = collectAnchorsById(); 152 var ids = Object.keys(byId); 153 if (!ids.length) { 154 return; 155 } 156 157 // Apply spins we already know about (cards added by AJAX after the 158 // first lookup) without another round trip. 159 var known = {}; 160 ids.forEach(function (id) { 161 if (lookedUp[id]) { 162 known[id] = lookedUp[id]; 163 } 164 }); 165 applySpins(byId, known); 166 167 var toQuery = ids.filter(function (id) { 168 return !(id in lookedUp); 169 }); 170 if (!toQuery.length) { 171 return; 172 } 173 174 fetch(CFG.ajaxUrl, { 175 method: 'POST', 176 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 177 body: 'action=spin360_lookup&ids=' + encodeURIComponent(toQuery.join(',')), 178 credentials: 'same-origin' 179 }) 180 .then(function (r) { return r.json(); }) 181 .then(function (res) { 182 var map = (res && res.success && res.data) ? res.data : {}; 183 toQuery.forEach(function (id) { 184 lookedUp[id] = map[id] || null; 185 }); 186 applySpins(byId, map); 187 }) 188 .catch(function () { /* network error â silently skip badges */ }); 189 } 190 191 function injectVdpButton() { 192 if (!CFG.isVdp || !CFG.vdpSrc) { 193 return; 194 } 195 // Idempotent: template already renders a spin slide/modal on this VDP. 196 if (document.querySelector('.orbit-spin, .autospin360, #spinModal')) { 197 return; 198 } 199 if (document.querySelector('.spin360-vdp-btn')) { 200 return; 201 } 202 203 var btn = document.createElement('button'); 204 btn.type = 'button'; 205 btn.className = 'spin360-vdp-btn ' + TRIGGER; 206 btn.setAttribute('data-spin-src', CFG.vdpSrc); 207 208 var img = document.createElement('img'); 209 img.src = CFG.iconUrl; 210 img.alt = ''; 211 var label = document.createElement('span'); 212 label.textContent = 'View in 360°'; 213 btn.appendChild(img); 214 btn.appendChild(label); 215 216 document.body.appendChild(btn); 217 } 218 219 ready(function () { 220 // Open from any trigger (listing badge or VDP button). 221 document.addEventListener('click', function (e) { 222 var trigger = e.target.closest('.' + TRIGGER); 223 if (!trigger) { 224 return; 225 } 226 // The badge can sit inside the /vehicles/ link (child markup wraps 227 // the image in the anchor) â stop the click from navigating. 228 e.preventDefault(); 229 e.stopPropagation(); 230 var src = trigger.getAttribute('data-spin-src'); 231 if (!src) { 232 var sid = trigger.getAttribute('data-spin-id'); 233 if (sid) { 234 src = CFG.orbitBase + encodeURIComponent(sid); 235 } 236 } 237 if (src) { 238 openModal(src); 239 } 240 }); 241 242 // Close interactions. 243 document.addEventListener('click', function (e) { 244 if (e.target.closest('.spin360-close')) { 245 e.preventDefault(); 246 closeModal(); 247 } else if (e.target.id === 'adSpin360Modal') { 248 closeModal(); 249 } 250 }); 251 document.addEventListener('keydown', function (e) { 252 if (e.key === 'Escape') { 253 closeModal(); 254 } 255 }); 256 257 scanListing(); 258 injectVdpButton(); 259 260 // Re-scan when AJAX filtering/pagination swaps in new cards. Debounced, 261 // and guarded by data-spin360-done so our own insertions don't loop. 262 var pending; 263 var observer = new MutationObserver(function () { 264 clearTimeout(pending); 265 pending = setTimeout(scanListing, 200); 266 }); 267 observer.observe(document.body, { childList: true, subtree: true }); 268 }); 269})();
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.