1/* SST Product Insights â bulk add to cart, recently viewed, GA4 events. 2 Vanilla JS, no dependencies. Config via window.SST_PI_CFG. */ 3(function () { 4 'use strict'; 5 6 var CFG = window.SST_PI_CFG || {}; 7 var RV_KEY = 'sst_rv'; 8 var RV_MAX = 12; 9 var RV_SHOW = 4; 10 11 function ready(fn) { 12 if (document.readyState === 'loading') { 13 document.addEventListener('DOMContentLoaded', fn); 14 } else { 15 fn(); 16 } 17 } 18 19 // ------------------------------------------------------------------ 20 // GA4 helper (SamuraiGA4 wrapper with dataLayer fallback) 21 // ------------------------------------------------------------------ 22 23 function track(payload) { 24 try { 25 if (window.SamuraiGA4 && typeof window.SamuraiGA4.push === 'function') { 26 window.SamuraiGA4.push(payload); 27 return; 28 } 29 window.dataLayer = window.dataLayer || []; 30 if (payload && payload.ecommerce) { 31 window.dataLayer.push({ ecommerce: null }); 32 } 33 window.dataLayer.push(payload); 34 } catch (e) { /* never break the page for analytics */ } 35 } 36 37 function itemFromCard(li, index) { 38 return { 39 item_id: li.getAttribute('data-pid') || li.getAttribute('data-sku') || '', 40 item_sku: li.getAttribute('data-sku') || '', 41 item_name: li.getAttribute('data-name') || '', 42 price: parseFloat(li.getAttribute('data-price') || '0') || 0, 43 quantity: 1, 44 index: typeof index === 'number' ? index : parseInt(li.getAttribute('data-position') || '0', 10) 45 }; 46 } 47 48 function listItems(section) { 49 var items = []; 50 section.querySelectorAll('[data-sst-pi-item]').forEach(function (li, i) { 51 items.push(itemFromCard(li, i)); 52 }); 53 return items; 54 } 55 56 function wireListTracking() { 57 var sections = document.querySelectorAll('[data-sst-pi-list]'); 58 if (!sections.length) { return; } 59 60 if ('IntersectionObserver' in window) { 61 var seen = {}; 62 var io = new IntersectionObserver(function (entries) { 63 entries.forEach(function (entry) { 64 if (!entry.isIntersecting) { return; } 65 var section = entry.target; 66 var listId = section.getAttribute('data-sst-pi-list'); 67 if (!listId || seen[listId]) { return; } 68 seen[listId] = true; 69 io.unobserve(section); 70 track({ 71 event: 'view_item_list', 72 ecommerce: { item_list_id: listId, item_list_name: listId, items: listItems(section) } 73 }); 74 }); 75 }, { threshold: 0.3 }); 76 sections.forEach(function (s) { io.observe(s); }); 77 } 78 79 document.addEventListener('click', function (event) { 80 var link = event.target.closest ? event.target.closest('[data-sst-pi-card-link]') : null; 81 if (link) { 82 var li = link.closest('[data-sst-pi-item]'); 83 var section = link.closest('[data-sst-pi-list]'); 84 if (li) { 85 track({ 86 event: 'select_item', 87 ecommerce: { 88 item_list_id: section ? section.getAttribute('data-sst-pi-list') : '', 89 items: [itemFromCard(li, null)] 90 } 91 }); 92 } 93 return; 94 } 95 var resource = event.target.closest ? event.target.closest('[data-sst-pi-link]') : null; 96 if (resource) { 97 track({ 98 event: 'select_content', 99 content_type: 'pdp_insights', 100 item_id: resource.getAttribute('data-sst-pi-link') || '' 101 }); 102 } 103 }, true); 104 } 105 106 // ------------------------------------------------------------------ 107 // Bulk add to cart 108 // ------------------------------------------------------------------ 109 110 var bulkNonce = ''; 111 112 function post(action, data) { 113 var body = new URLSearchParams(); 114 body.set('action', action); 115 Object.keys(data || {}).forEach(function (k) { body.set(k, data[k]); }); 116 return fetch(CFG.ajaxUrl || '/wp-admin/admin-ajax.php', { 117 method: 'POST', 118 credentials: 'same-origin', 119 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 120 body: body.toString() 121 }).then(function (res) { 122 return res.json().then(function (json) { return { status: res.status, json: json }; }); 123 }); 124 } 125 126 function fetchNonce() { 127 return post('sst_pi_bulk_add_nonce', {}).then(function (res) { 128 if (res.json && res.json.success && res.json.data && res.json.data.nonce) { 129 bulkNonce = res.json.data.nonce; 130 } 131 return bulkNonce; 132 }); 133 } 134 135 function formatMoney(amount) { 136 var currency = CFG.currency || 'JPY'; 137 try { 138 return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency, maximumFractionDigits: currency === 'JPY' ? 0 : 2 }).format(amount); 139 } catch (e) {
140 return 'Â¥' + Math.round(amount).toLocaleString(); 141 } 142 } 143 144 function applyFragments(fragments) { 145 if (!fragments) { return false; } 146 var applied = false; 147 Object.keys(fragments).forEach(function (selector) { 148 var el = document.querySelector(selector); 149 if (el) { 150 el.outerHTML = fragments[selector]; 151 applied = true; 152 } 153 }); 154 return applied; 155 } 156 157 function wireBulkAdd() { 158 var section = document.querySelector('[data-sst-pi-bulk]'); 159 if (!section) { return; } 160 var bar = section.querySelector('[data-sst-pi-bulkbar]'); 161 var summary = section.querySelector('[data-sst-pi-bulk-summary]'); 162 var button = section.querySelector('[data-sst-pi-bulk-btn]'); 163 if (!bar || !summary || !button) { return; } 164 165 var checks = section.querySelectorAll('[data-sst-pi-bulk-check]'); 166 if (!checks.length) { bar.hidden = true; return; } 167 168 function selected() { 169 var out = []; 170 checks.forEach(function (box) { 171 if (box.checked) { 172 var li = box.closest('[data-sst-pi-item]'); 173 if (li) { out.push(li); } 174 } 175 }); 176 return out; 177 } 178 179 function refresh() { 180 var lis = selected(); 181 if (!lis.length) { 182 bar.hidden = true; 183 return; 184 } 185 var total = 0; 186 lis.forEach(function (li) { total += parseFloat(li.getAttribute('data-price') || '0') || 0; }); 187 summary.textContent = lis.length + (lis.length === 1 ? ' item' : ' items') + ' · ' + formatMoney(total); 188 bar.hidden = false; 189 } 190 191 checks.forEach(function (box) { 192 box.addEventListener('change', function () { 193 refresh(); 194 if (!bulkNonce) { fetchNonce(); } 195 }); 196 }); 197 198 button.addEventListener('click', function () { 199 var lis = selected(); 200 if (!lis.length || button.disabled) { return; } 201 var max = CFG.bulkMax || 10; 202 lis = lis.slice(0, max); 203 var items = lis.map(function (li) { 204 return { 205 product_id: parseInt(li.getAttribute('data-pid') || '0', 10), 206 variation_id: parseInt(li.getAttribute('data-vid') || '0', 10), 207 quantity: 1 208 }; 209 }); 210 211 button.disabled = true; 212 var originalLabel = button.textContent; 213 button.textContent = 'Addingâ¦'; 214 215 var send = function (retried) { 216 return post('sst_pi_bulk_add', { nonce: bulkNonce, items: JSON.stringify(items) }).then(function (res) { 217 if (res.status === 403 && !retried) { 218 return fetchNonce().then(function () { return send(true); }); 219 } 220 return res; 221 }); 222 }; 223 224 (bulkNonce ? Promise.resolve() : fetchNonce()).then(function () { 225 return send(false); 226 }).then(function (res) { 227 var data = res.json && res.json.success ? res.json.data : null; 228 if (!data) { throw new Error('bulk add failed'); } 229 applyFragments(data.fragments); 230 if (window.jQuery) { 231 window.jQuery(document.body).trigger('wc_fragments_refreshed'); 232 } 233 var ga4Items = []; 234 var total = 0; 235 lis.forEach(function (li, i) { 236 if (data.added.indexOf(parseInt(li.getAttribute('data-pid') || '0', 10)) !== -1) { 237 ga4Items.push(itemFromCard(li, i)); 238 total += parseFloat(li.getAttribute('data-price') || '0') || 0; 239 } 240 }); 241 if (ga4Items.length) { 242 track({ 243 event: 'add_to_cart', 244 ecommerce: { currency: CFG.currency || 'JPY', value: total, items: ga4Items } 245 }); 246 } 247 button.textContent = data.failed && data.failed.length ? 'Added (some skipped)' : 'Added to cart â'; 248 checks.forEach(function (box) { box.checked = false; }); 249 window.setTimeout(function () { 250 button.textContent = originalLabel; 251 button.disabled = false; 252 refresh(); 253 }, 2400); 254 }).catch(function () { 255 button.textContent = 'Could not add â try again'; 256 window.setTimeout(function () { 257 button.textContent = originalLabel; 258 button.disabled = false; 259 }, 2400); 260 }); 261 }); 262 } 263 264 // ------------------------------------------------------------------ 265 // Recently viewed (localStorage, schema {id,name,url,img,price}) 266 // ------------------------------------------------------------------ 267 268 function readRv() { 269 try { 270 var raw = window.localStorage.getItem(RV_KEY); 271 var list = raw ? JSON.parse(raw) : []; 272 return Array.isArray(list) ? list : []; 273 } catch (e) { 274 return []; 275 } 276 } 277 278 function writeRv(list) { 279 try { 280 window.localStorage.setItem(RV_KEY, JSON.stringify(list.slice(0, RV_MAX))); 281 } catch (e) { /* storage full/blocked */ } 282 } 283 284 function currentProductEntry() { 285 var p = CFG.product; 286 if (!p || !p.id) { return null; } 287 var img = ''; 288 var el = document.querySelector('.woocommerce-product-gallery__image img, .woocommerce-product-gallery img, .sst-product-main img'); 289 if (el) { img = el.currentSrc || el.src || ''; } 290 return { id: p.id, name: p.name || '', url: p.url || window.location.pathname, img: img, price: p.price || '' }; 291 } 292 293 function wireRecentlyViewed() { 294 var section = document.querySelector('[data-sst-pi-recent]'); 295 var grid = document.querySelector('[data-sst-pi-recent-grid]'); 296 var entry = currentProductEntry(); 297 var list = readRv(); 298 299 if (grid && section) { 300 var currentId = entry ? entry.id : 0; 301 var show = list.filter(function (it) { return it && it.id && it.id !== currentId; }).slice(0, RV_SHOW); 302 if (show.length) { 303 show.forEach(function (it) { 304 var li = document.createElement('li'); 305 li.className = 'product sst-product-card sst-fbt__card sst-pi-recent__card'; 306 var a = document.createElement('a'); 307 a.className = 'sst-product-card__link'; 308 a.href = it.url; 309 var media = document.createElement('span'); 310 media.className = 'sst-product-card__media'; 311 if (it.img) { 312 var img = document.createElement('img'); 313 img.src = it.img; 314 img.alt = it.name; 315 img.loading = 'lazy'; 316 img.decoding = 'async'; 317 media.appendChild(img); 318 } 319 var body = document.createElement('span'); 320 body.className = 'sst-product-card__body'; 321 var title = document.createElement('span'); 322 title.className = 'woocommerce-loop-product__title'; 323 title.textContent = it.name; 324 body.appendChild(title); 325 if (it.price) { 326 var price = document.createElement('span'); 327 price.className = 'price'; 328 price.textContent = it.price; 329 body.appendChild(price); 330 } 331 a.appendChild(media); 332 a.appendChild(body); 333 li.appendChild(a); 334 grid.appendChild(li); 335 }); 336 section.hidden = false;
337 } 338 } 339 340 if (entry) { 341 var next = list.filter(function (it) { return it && it.id !== entry.id; }); 342 next.unshift(entry); 343 writeRv(next); 344 } 345 } 346 347 // ------------------------------------------------------------------ 348 // Points line (v0.4.0, per-product base rate à rank multiplier since v0.5.0) â "Earn N pts" under the price. 349 // The skeleton ships hidden in the shared-cache HTML; the visitor's own 350 // earn rate arrives from GET /sst-points/v1/me (cookie auth, no-store). 351 // Fail-closed: until that answer lands, nothing is shown. 352 // ------------------------------------------------------------------ 353 354 // PHP parity: SST_Points_Rates::points_for_rate() = floor(round(b*r, 6)). 355 function pointsFor(base, rate) { 356 return Math.floor(Math.round(base * rate * 1e6) / 1e6); 357 } 358 359 // v0.5.0 (contract §3): effective rate = round(baseRate à multiplier, 8); 360 // points = floor(round(price à rate, 6)) â one floor, as in sst-points. 361 function lineRate(baseRate, mult) { 362 return Math.round(baseRate * mult * 1e8) / 1e8; 363 } 364 365 // Contract §2: a base rate is usable only when 0 < v <= 0.01. 366 function validBaseRate(v) { 367 v = typeof v === 'number' ? v : parseFloat(v); 368 return (isFinite(v) && v > 0 && v <= 0.01) ? v : 0; 369 } 370 371 // "0.5%" / "0.25%" / "0.375%" / "0.1%": at most 3 decimals, trailing zeros 372 // trimmed, at least 1 decimal (PHP rate_percent_label() format). 373 function ratePercentLabel(rate) { 374 var s = (Math.round(rate * 100 * 1000) / 1000).toFixed(3).replace(/0+$/, ''); 375 if (s.charAt(s.length - 1) === '.') { s += '0'; } 376 return s + '%'; 377 } 378 379 // Multiplier number only ("3", "1.5"); the à sign belongs to the template. 380 function multiplierNumber(m) { 381 var s = (Math.round(m * 10000) / 10000).toFixed(4).replace(/0+$/, ''); 382 return s.charAt(s.length - 1) === '.' ? s.slice(0, -1) : s; 383 } 384 385 // Rank multiplier from /me: `multiplier` (sst-points >= 0.6.0), else 386 // rate / baseRateMax (`rate` stays the max-band rate = 0.005 à multiplier). 387 // Guests are Ã1. 0 means unknown -> the line stays hidden (fail-closed). 388 function meMultiplier(data, baseRateMax) { 389 if (!data || !data.loggedIn) { return 1; } 390 if (typeof data.multiplier === 'number' && isFinite(data.multiplier) && data.multiplier > 0) { 391 return data.multiplier; 392 } 393 var max = (typeof data.baseRateMax === 'number' && data.baseRateMax > 0) ? data.baseRateMax : baseRateMax; 394 if (typeof data.rate === 'number' && isFinite(data.rate) && data.rate > 0 && max > 0) { 395 return Math.round(data.rate / max * 10000) / 10000; 396 } 397 return 0; 398 } 399 400 // Positional printf placeholders, literal (no $-pattern surprises). 401 function fillTpl(tpl, values) { 402 var out = tpl; 403 for (var i = 0; i < values.length; i++) { 404 out = out.split('%' + (i + 1) + '$s').join(values[i]); 405 } 406 return out; 407 } 408 409 function wireDutyNote() { 410 var el = document.querySelector('[data-sst-pi-duty]'); 411 if (!el || !window.fetch) { return; } 412 var ddp = (el.getAttribute('data-ddp') || '').split(','); 413 /* Cloudflare answers this at the edge â it never reaches WordPress and is 414 never cached, so it is safe to call from a fully cached page. */ 415 fetch('/cdn-cgi/trace', { credentials: 'omit', cache: 'no-store' }) 416 .then(function (r) { return r.ok ? r.text() : ''; }) 417 .then(function (t) { 418 var m = /(?:^|\n)loc=([A-Z]{2})/.exec(t || ''); 419 if (m && ddp.indexOf(m[1]) !== -1) { 420 el.hidden = false; 421 track({ event: 'sst_pi_duty_note_shown', country: m[1] }); 422 } 423 }) 424 .catch(function () { /* stay hidden */ }); 425 } 426 427 function wirePointsLine() { 428 var el = document.querySelector('[data-sst-pi-points]'); 429 if (!el) { return; } 430 // v0.5.0: data-base-rate is this product's (or the price-matching 431 // variation's) base points rate; the visitor's rank multiplier comes 432 // from /me. pts = floor(round(price à round(base à mult, 8), 6)). 433 var pageBaseRate = validBaseRate(el.getAttribute('data-base-rate')); 434 var baseRate = pageBaseRate; 435 var baseRateMax = validBaseRate(el.getAttribute('data-base-rate-max')) || 0.005; 436 var multMax = parseFloat(el.getAttribute('data-mult-max')); 437 var basePrice = parseFloat(el.getAttribute('data-price')) || 0; 438 var price = basePrice; 439 var state = { ready: false, live: false, loggedIn: false, mult: 0, lineModel: false, rate: 0 }; 440 441 // sst-signup-banner-impl-20261002 (W3, P4): guest-only sign-up copy. The server prints it 442 // translated as hidden text nodes inside this <p> (the dictionary never reaches data-* or 443 // JS strings); read them once, before the first render clears the element. 444 function tplText(name) { 445 var t = el.querySelector('[data-sst-pi-tpl="' + name + '"]'); 446 return t ? (t.textContent || '').trim() : ''; 447 } 448 var signup = { 449 lead: tplText('signup-lead'), 450 cta: tplText('signup-cta'), 451 terms: tplText('signup-terms'), 452 url: el.getAttribute('data-signup-url') || '/my-account/', 453 seen: false 454 }; 455 456 function signupEvent(action) {
457 try { 458 document.dispatchEvent(new CustomEvent('sst:signup-surface', { 459 detail: { surface: 'pdp', action: action, campaign: 'points-signup' } 460 })); 461 } catch (e) { /* old browser */ } 462 } 463 464 // View = the block is at least 50% on screen once (same rule as W2's surfaces, audit A2 P2-5), 465 // not the moment it is rendered. Re-renders observe the new node; the event fires once per page. 466 var viewIo = null; 467 function observeView(node) { 468 if (signup.seen) { return; } 469 function fire() { 470 if (signup.seen) { return true; } 471 signup.seen = true; 472 signupEvent('view'); 473 return true; 474 } 475 if (!('IntersectionObserver' in window)) { fire(); return; } 476 if (viewIo) { viewIo.disconnect(); } 477 viewIo = new IntersectionObserver(function (entries) { 478 entries.forEach(function (entry) { 479 if (entry.isIntersecting && entry.target.isConnected && fire()) { viewIo.disconnect(); } 480 }); 481 }, { threshold: 0.5 }); 482 viewIo.observe(node); 483 } 484 485 // Appends the guest block (lead + Sign up link, then the conditions line). Guests only. 486 function appendSignup() { 487 if (state.loggedIn || !signup.lead) { return false; } 488 var box = document.createElement('span'); 489 box.className = 'sst-pi-points__signup'; 490 box.setAttribute('data-sst-signup-surface', 'pdp'); 491 var lead = document.createElement('span'); 492 lead.className = 'sst-pi-points__signup-lead'; 493 lead.appendChild(document.createTextNode(signup.lead + ' ')); 494 if (signup.cta) { 495 var a = document.createElement('a'); 496 a.className = 'sst-pi-points__signup-cta'; 497 a.href = signup.url; 498 a.textContent = signup.cta; 499 a.addEventListener('click', function () { signupEvent('click'); }); 500 lead.appendChild(a); 501 } 502 box.appendChild(lead); 503 if (signup.terms) { 504 var terms = document.createElement('span'); 505 terms.className = 'sst-pi-points__signup-terms'; 506 terms.textContent = signup.terms; 507 box.appendChild(terms); 508 } 509 el.appendChild(box); 510 observeView(box); 511 return true; 512 } 513 514 // No earn figure to show (e.g. < 1 pt): a guest still gets the sign-up copy. 515 function signupOnly() { 516 if (!state.ready || !state.live || state.loggedIn || !signup.lead) { return false; } 517 el.textContent = ''; 518 appendSignup(); 519 el.hidden = false; 520 return true; 521 } 522 523 // R2-03: the per-item model is shown only when /me (no-store) says the 524 // server pays it (lineModel === true). Missing/false = sst-points < 0.6.0 525 // or the line model switched off -> the legacy whole-order rate is what 526 // gets credited, so show exactly the 0.4.4 line. The cached HTML carries 527 // no mode, so flipping the option needs no PDP purge. 528 function renderLegacy() { 529 var rate = (state.loggedIn && state.rate > 0) ? state.rate : baseRateMax; 530 var pts = pointsFor(price, rate); 531 if (!(pts >= 1)) { 532 if (!signupOnly()) { el.hidden = true; } 533 return; 534 } 535 el.textContent = ''; 536 var earn = document.createElement('span'); 537 earn.className = 'sst-pi-points__earn'; 538 earn.textContent = (el.getAttribute('data-tpl-earn') || 'Earn %s pts') 539 .replace('%s', pts.toLocaleString('en-US')); 540 el.appendChild(earn); 541 if (!state.loggedIn) { 542 var signin = document.createElement('a'); 543 signin.className = 'sst-pi-points__signin'; 544 signin.href = el.getAttribute('data-signin-url') || '/my-account/'; 545 signin.textContent = el.getAttribute('data-tpl-signin') || 'Sign in to collect points'; 546 el.appendChild(signin); 547 } 548 appendSignup(); 549 el.hidden = false; 550 } 551 552 function render() { 553 if (!state.ready || !state.live || !(price > 0)) { 554 if (!signupOnly()) { el.hidden = true; } 555 return; 556 } 557 if (!state.lineModel) { 558 renderLegacy(); 559 return; 560 } 561 if (!(baseRate > 0) || !(state.mult > 0)) { 562 if (!signupOnly()) { el.hidden = true; } 563 return; 564 } 565 var pts = pointsFor(price, lineRate(baseRate, state.mult)); 566 if (!(pts >= 1)) { 567 if (!signupOnly()) { el.hidden = true; } 568 return; 569 } 570 var label = fillTpl( 571 el.getAttribute('data-tpl-earn-rate') || 'Earn %1$s pts (%2$s \u00d7 %3$s)',
572 [pts.toLocaleString('en-US'), ratePercentLabel(baseRate), multiplierNumber(state.mult)] 573 ); 574 el.textContent = ''; 575 var earn = document.createElement('span'); 576 earn.className = 'sst-pi-points__earn'; 577 earn.textContent = label; 578 el.appendChild(earn); 579 if (!state.loggedIn) { 580 var signin = document.createElement('a'); 581 signin.className = 'sst-pi-points__signin'; 582 signin.href = el.getAttribute('data-signin-url') || '/my-account/'; 583 signin.textContent = (isFinite(multMax) && multMax > 1) 584 ? (el.getAttribute('data-tpl-signin-rank') || 'Sign in to collect points \u2014 up to \u00d7%s with rank') 585 .split('%s').join(multiplierNumber(multMax)) 586 : (el.getAttribute('data-tpl-signin') || 'Sign in to collect points'); 587 el.appendChild(signin); 588 } 589 appendSignup(); 590 el.hidden = false; 591 } 592 593 function gotMe(data) { 594 state.ready = true; 595 state.live = !!(data && data.live); 596 state.loggedIn = !!(data && data.loggedIn); 597 state.mult = meMultiplier(data, baseRateMax); 598 state.lineModel = !!(data && data.lineModel === true); 599 state.rate = (data && typeof data.rate === 'number' && isFinite(data.rate)) ? data.rate : 0; 600 render(); 601 } 602 603 // Only an overlapping points GET may be shared with the header. Completed 604 // answers and the header's session cache are never reused here. 605 var pointsRequests = window.sstPointsRequests; 606 function fetchMe() { 607 if (!window.fetch) return; 608 var started = pointsRequests ? pointsRequests.generation() : 0; 609 var options = { 610 method: 'GET', 611 credentials: 'same-origin', 612 headers: { Accept: 'application/json' } 613 }; 614 var request = pointsRequests 615 ? pointsRequests.request(el.getAttribute('data-me'), options) 616 : fetch(el.getAttribute('data-me'), options).then(function (res) { 617 if (!res.ok) { throw new Error('me:' + res.status); } 618 return res.json(); 619 }); 620 request.then(function (data) { 621 if (pointsRequests && started !== pointsRequests.generation()) return; 622 if (data && typeof data.live === 'boolean') { gotMe(data); } 623 }).catch(function () { /* stay hidden */ }); 624 } 625 if (pointsRequests) { 626 pointsRequests.onInvalidate(function () { 627 state.ready = false; 628 render(); 629 fetchMe(); 630 }); 631 } 632 fetchMe(); 633 634 // Variable products: follow the selected variation's live price; a reset 635 // falls back to the range floor the server rendered. 636 if (window.jQuery) { 637 window.jQuery('form.variations_form') 638 .on('found_variation', function (e, variation) { 639 if (variation && typeof variation.display_price !== 'undefined') { 640 price = parseFloat(variation.display_price) || 0; 641 // No per-variation rate (stale variation data) -> hide, never guess. 642 baseRate = (typeof variation.sst_points_base_rate !== 'undefined') 643 ? validBaseRate(variation.sst_points_base_rate) : 0; 644 render(); 645 } 646 }) 647 .on('reset_data', function () { 648 price = basePrice; 649 baseRate = pageBaseRate; 650 render(); 651 }); 652 } 653 } 654 655 // ------------------------------------------------------------------ 656 // QO CTA clicks (v0.4.0) 657 // ------------------------------------------------------------------ 658 659 function wireQoCtaTracking() { 660 document.addEventListener('click', function (e) { 661 var a = e.target && e.target.closest ? e.target.closest('[data-sst-pi-cta]') : null; 662 if (!a) { return; } 663 track({ 664 event: 'select_content', 665 content_type: 'pdp_qo_cta', 666 item_id: a.getAttribute('data-sst-pi-cta') + ':' + (a.getAttribute('data-sst-pi-cta-pos') || '') 667 }); 668 }, true); 669 } 670 671 // ------------------------------------------------------------------ 672 // Reviews expander (v0.4.0) â long lists clamp to the first few reviews. 673 // ------------------------------------------------------------------ 674 675 function wireReviewsExpander() { 676 var section = document.querySelector('[data-sst-pi-reviews]'); 677 if (!section) { return; } 678 var list = section.querySelector('.commentlist'); 679 if (!list) { return; } 680 var items = list.children; 681 var visible = parseInt(section.getAttribute('data-visible'), 10) || 6; 682 if (items.length <= visible) { return; } 683 // A #comment-N permalink (review-approved mails link like this) must not 684 // land on a display:none review â skip clamping for that visit. 685 if (/^#comment-\d+$/.test(window.location.hash) && section.querySelector(window.location.hash)) { return; } 686 for (var i = visible; i < items.length; i++) { 687 items[i].classList.add('sst-pi-reviews__clamped'); 688 } 689 var btn = document.createElement('button'); 690 btn.type = 'button'; 691 btn.className = 'sst-pi-reviews__showall'; 692 btn.textContent = (section.getAttribute('data-tpl-showall') || 'Show all %s reviews') 693 .replace('%s', String(items.length)); 694 btn.addEventListener('click', function () { 695 for (var j = 0; j < items.length; j++) { 696 items[j].classList.remove('sst-pi-reviews__clamped'); 697 } 698 btn.parentNode.removeChild(btn); 699 }); 700 list.parentNode.appendChild(btn); 701 } 702 703 // Duty note: start the edge geo lookup at parse time (footer script â DOM 704 // is already built), so the reveal usually lands before first paint settles. 705 try { wireDutyNote(); } catch (e) { /* never break the page */ } 706 707 ready(function () { 708 // Each wiring is independent â one throwing must not kill the rest. 709 [wireListTracking, wireBulkAdd, wireRecentlyViewed, wirePointsLine, wireQoCtaTracking, wireReviewsExpander].forEach(function (fn) {
710 try { fn(); } catch (e) { /* never break the page */ } 711 }); 712 }); 713})();
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.