1/* SST Restock Notify â back-in-stock bell for Quick Order rows and product pages. */ 2(function () { 3 'use strict'; 4 5 var CFG = window.SST_RSN || {}; 6 var T = CFG.i18n || {}; 7 var watched = new Set(); 8 var identityLoaded = false; 9 var identityKnown = false; 10 // Only a signed-in customer's newsletter opt-in is acted on by the server: 11 // /watch takes an address as a parameter and needs no proof the caller owns 12 // it, so honouring the tick box for guests would let anyone subscribe anyone. 13 // The box is hidden rather than ignored - offering a control that quietly 14 // does nothing is the worse of the two. 15 var identityLoggedIn = false; 16 // This person has unsubscribed from the newsletter before. The tick box is still 17 // offered - a customer is allowed to change their mind - but it starts CLEARED, so 18 // opting back in has to be something they did on purpose rather than something 19 // they failed to notice. Only ever true for a signed-in customer: /mine will not 20 // answer this question about an address it cannot prove belongs to the asker. 21 // 22 // Defaults to false, which means the box defaults ticked when /mine has not 23 // answered yet or answered with an error. That is deliberate: guessing "they 24 // unsubscribed" would hide the offer from people who never did, and guessing the 25 // other way costs nothing, because the worker refuses to resurrect an unsubscribed 26 // address on the newsletter list no matter what this page sends it. 27 var identityNewsletterOptout = false; 28 var identityEmail = ''; 29 var pendingButtons = []; 30 var lastClickedRow = null; 31 var NEWS_KEY = 'sstRsnNewsletter'; 32 33 function newsletterPreference() { 34 try { 35 return window.localStorage.getItem(NEWS_KEY); 36 } catch (error) { 37 return null; 38 } 39 } 40 41 function rememberNewsletter(value) { 42 try { 43 window.localStorage.setItem(NEWS_KEY, value ? '1' : '0'); 44 } catch (error) { /* private mode */ } 45 } 46 47 function t(key, fallback) {
48 return T[key] || fallback; 49 } 50 51 function api(path, options) { 52 var opts = options || {}; 53 var headers = { 'Content-Type': 'application/json' }; 54 return fetch(CFG.root + path, { 55 method: opts.method || 'GET', 56 credentials: 'same-origin', 57 cache: 'no-store', 58 headers: headers, 59 body: opts.body ? JSON.stringify(opts.body) : undefined 60 }).then(function (response) { 61 return response.json().catch(function () { return {}; }).then(function (data) { 62 return { status: response.status, data: data }; 63 }); 64 }); 65 } 66 67 function loadIdentity() { 68 return api('/mine').then(function (result) { 69 identityLoaded = true; 70 var data = result.data || {}; 71 identityKnown = !!data.known; 72 identityLoggedIn = !!data.logged_in; 73 identityNewsletterOptout = !!data.newsletter_optout; 74 identityEmail = data.email_masked || ''; 75 watched = new Set(data.lines || []); 76 syncAllButtons(); 77 }).catch(function () { 78 identityLoaded = true; 79 }); 80 } 81 82 // ---- button ------------------------------------------------------------ 83 84 var BELL = '<svg viewBox="0 0 20 20" width="13" height="13" aria-hidden="true" focusable="false">' 85 + '<path fill="currentColor" d="M10 2a1 1 0 0 1 1 1v.6a4.6 4.6 0 0 1 3.6 4.49v2.6l1.2 2.05a.8.8 0 0 1-.69 1.2H4.89a.8.8 0 0 1-.69-1.2L5.4 10.7V8.09A4.6 4.6 0 0 1 9 3.6V3a1 1 0 0 1 1-1Zm2 13.2a2 2 0 0 1-4 0h4Z"/>' 86 + '</svg>'; 87 88 function makeButton(info) { 89 var button = document.createElement('button'); 90 button.type = 'button'; 91 button.className = 'sst-rsn-btn'; 92 button.dataset.lineId = String(info.lineId); 93 button.dataset.offerType = info.offerType || ''; 94 button.dataset.offerLabel = info.offerLabel || ''; 95 button.dataset.source = info.source || 'qo'; 96 button.innerHTML = BELL + '<span class="sst-rsn-btn__label"></span>'; 97 syncButton(button); 98 pendingButtons.push(button); 99 return button; 100 } 101 102 // ---- multi-line bell --------------------------------------------------- 103 104 // One button standing for every variation of one product: a pre-order box in 105 // both its Sealed and its No Shrink form, say. It is the same bell and the same 106 // dialog as the single-line one - it just signs the reader up to each of its 107 // lines in turn. The markup is a contract shared with the theme, which renders 108 // the hero copy of this button server-side in nine languages; anything read off 109 // the button here has to be readable off that one too. 110 111 function multiLines(button) { 112 var raw = button.dataset.multiLines; 113 if (!raw) return null; 114 var parsed; 115 try { 116 parsed = JSON.parse(raw); 117 } catch (error) { 118 return null; 119 } 120 if (!parsed || typeof parsed !== 'object') return null; 121 var lines = [];
122 Object.keys(parsed).forEach(function (key) { 123 var lineId = parseInt(key, 10); 124 if (!lineId) return; 125 var info = parsed[key] || {}; 126 lines.push({ 127 lineId: lineId, 128 offerType: info.offerType || '', 129 offerLabel: info.offerLabel || '' 130 }); 131 }); 132 return lines.length ? lines : null; 133 } 134 135 /** Every line one click on this button signs up to, in order. */ 136 function buttonLines(button) { 137 var multi = multiLines(button); 138 if (multi) return multi; 139 var lineId = parseInt(button.dataset.lineId, 10); 140 if (!lineId) return []; 141 return [{ 142 lineId: lineId, 143 offerType: button.dataset.offerType || '', 144 offerLabel: button.dataset.offerLabel || '' 145 }]; 146 } 147 148 // A multi-line button is ON only when every one of its lines is watched. Half on 149 // would be a lie: the reader would think they were covered for the variation they 150 // actually wanted. The half that is missing is what the next click sends. 151 function buttonIsOn(button) { 152 var lines = buttonLines(button); 153 if (!lines.length) return false; 154 return lines.every(function (line) { 155 return watched.has(line.lineId); 156 }); 157 } 158 159 /** Lines not yet watched - the only ones a click needs to send. */ 160 function unwatchedLines(button) { 161 return buttonLines(button).filter(function (line) { 162 return !watched.has(line.lineId); 163 }); 164 } 165 166 /** Is this product still ahead of its release moment? */ 167 function isTeaser(button) { 168 var release = parseInt(button.dataset.releaseStartMs || '', 10); 169 return !!release && Date.now() < release; 170 } 171 172 function makeMultiButton(info) { 173 var button = document.createElement('button'); 174 button.type = 'button'; 175 button.className = 'sst-rsn-btn sst-rsn-btn--multi'; 176 button.dataset.multiLines = JSON.stringify(info.lines || {}); 177 button.dataset.source = info.source || 'product'; 178 if (info.productName) button.dataset.productName = info.productName; 179 if (info.releaseStartMs) button.dataset.releaseStartMs = String(info.releaseStartMs); 180 button.innerHTML = '<span class="sst-rsn-btn__icon">' + BELL + '</span>' 181 + '<span class="sst-rsn-btn__label"></span>'; 182 button.querySelector('.sst-rsn-btn__label').textContent = info.label || ''; 183 adopt(button); 184 return button; 185 } 186 187 function syncButton(button) { 188 var multi = !!button.dataset.multiLines; 189 var on = buttonIsOn(button); 190 var label = button.querySelector('.sst-rsn-btn__label'); 191 // The off wording of a multi-line button belongs to whoever rendered it: the 192 // theme writes it server-side in the reader's language, and this script has no 193 // business replacing that with English. Keep it, so turning the bell off puts 194 // the original sentence back. 195 if (multi && label && button.dataset.offLabel === undefined) { 196 var initial = (label.textContent || '').trim(); 197 if (initial) button.dataset.offLabel = initial; 198 } 199 // The body observer also sees our label's childList changes. Replacing an 200 // equal textContent schedules another scan forever, even on an idle page. 201 // Preserve equal text (and translation wrappers) and write only changes. 202 if (button.classList.contains('is-on') !== on) button.classList.toggle('is-on', on); 203 var pressed = on ? 'true' : 'false'; 204 if (button.getAttribute('aria-pressed') !== pressed) button.setAttribute('aria-pressed', pressed); 205 var title = on ? t('watchingAria', 'You will be notified. Click to turn off.') : t('notifyAria', 'Email me when this item is back in stock'); 206 if (button.title !== title) button.title = title; 207 if (!label) return; 208 if (multi) { 209 var multiText = on ? t('watching', 'Notify: ON') : (button.dataset.offLabel || t('notify', 'Notify me')); 210 if (label.textContent !== multiText) label.textContent = multiText; 211 return; 212 } 213 // Quick Order offer cells are ~86px wide, so they get the short wording. 214 var compact = button.dataset.source !== 'product'; 215 if (on) { 216 var onText = compact ? t('watchingShort', 'ON') : t('watching', 'Notify: ON'); 217 if (label.textContent !== onText) label.textContent = onText; 218 } else { 219 var offText = compact ? t('notifyShort', 'Notify') : t('notify', 'Notify me'); 220 if (label.textContent !== offText) label.textContent = offText; 221 } 222 } 223 224 /** Put a button under this script's care exactly once. */ 225 function adopt(button) { 226 if (button.dataset.rsnAdopted) return; 227 button.dataset.rsnAdopted = '1'; 228 pendingButtons.push(button); 229 syncButton(button); 230 } 231 232 // Buttons this script did not build. The theme renders the hero bell server-side 233 // so it is readable and translated before any script runs; it still needs its ON 234 // state resolved from /mine like every other bell on the page. 235 function adoptRendered(root) { 236 var scope = root && root.querySelectorAll ? root : document;
237 scope.querySelectorAll('.sst-rsn-btn[data-multi-lines]:not([data-rsn-adopted])').forEach(adopt); 238 } 239 240 function syncAllButtons() { 241 pendingButtons = pendingButtons.filter(function (button) { 242 return button.isConnected; 243 }); 244 pendingButtons.forEach(syncButton); 245 } 246 247 // ---- quick order injection -------------------------------------------- 248 249 // Action states whose cells get a bell instead of purchase UI. quick-order.js 250 // renders a "Coming soon" span for coming-soon cells; the bell replaces it. 251 var WATCHABLE_STATES = ['unavailable', 'coming-soon']; 252 var WATCHABLE_CELLS = 'td.sst-qo__offer[data-qo-action-state="unavailable"], td.sst-qo__offer[data-qo-action-state="coming-soon"]'; 253 254 function clearActionPlaceholders(action) { 255 action.querySelectorAll('.sst-qo__offer-action-empty, .sst-qo__coming-soon').forEach(function (node) { 256 node.remove(); 257 }); 258 } 259 260 function injectQuickOrder(root) { 261 var scope = root && root.querySelectorAll ? root : document; 262 // Stale bells: a cell that went back in stock has its action markup rebuilt 263 // by quick-order.js, but clean up anyway so nothing survives a partial update. 264 scope.querySelectorAll('.sst-rsn-btn[data-source^="qo"]').forEach(function (button) { 265 var owner = button.closest('td.sst-qo__offer'); 266 if (owner && WATCHABLE_STATES.indexOf(owner.dataset.qoActionState) === -1) button.remove(); 267 }); 268 scope.querySelectorAll(WATCHABLE_CELLS).forEach(function (cell) { 269 var action = cell.querySelector('.sst-qo__offer-action'); 270 if (!action || action.querySelector('.sst-rsn-btn')) return; 271 var lineId = parseInt(cell.dataset.qoLineProductId || cell.dataset.qoVariationId || cell.dataset.qoProductId, 10); 272 if (!lineId) return; 273 clearActionPlaceholders(action); 274 action.appendChild(makeButton({ 275 lineId: lineId, 276 offerType: cell.dataset.qoOfferType || '', 277 offerLabel: cell.dataset.qoOfferLabel || '', 278 source: 'qo' 279 })); 280 }); 281 } 282 283 function injectModal() { 284 var modal = document.getElementById('sst-qo-modal'); 285 if (!modal) return; 286 var rows = modal.querySelectorAll('.sst-qo__modal-offer'); 287 if (!rows.length) return; 288 var sourceCells = lastClickedRow ? lastClickedRow.querySelectorAll('td.sst-qo__offer') : []; 289 rows.forEach(function (row, index) { 290 var action = row.querySelector('.sst-qo__modal-offer-action'); 291 if (!action || WATCHABLE_STATES.indexOf(action.dataset.qoActionState) === -1) return; 292 if (action.querySelector('.sst-rsn-btn')) return; 293 var cell = sourceCells[index]; 294 if (!cell) return; 295 var lineId = parseInt(cell.dataset.qoLineProductId || cell.dataset.qoVariationId || cell.dataset.qoProductId, 10); 296 if (!lineId) return; 297 clearActionPlaceholders(action); 298 action.appendChild(makeButton({ 299 lineId: lineId, 300 offerType: cell.dataset.qoOfferType || '', 301 offerLabel: cell.dataset.qoOfferLabel || '', 302 source: 'qo-modal' 303 })); 304 }); 305 } 306 307 // ---- product page ------------------------------------------------------ 308 309 /** 310 * Does this variations form have anything to choose between? 311 * 312 * A product still behind its release date has no purchasable variations, so Woo 313 * prints `data-product_variations="[]"` and leaves the selects empty. Nothing ever 314 * fires `show_variation` on such a form, which is why the bell below waits for the 315 * answer to this question instead of for that event. 316 * 317 * `false` in that attribute is not the same as empty: Woo writes it when the set is 318 * too large to inline, and the selects are populated anyway. Options decide. 319 */ 320 function formHasVariations(form) { 321 if (!form) return false; 322 var raw = form.getAttribute('data-product_variations'); 323 if (raw && raw !== 'false') { 324 try { 325 var parsed = JSON.parse(raw); 326 if (Array.isArray(parsed) && parsed.length) return true; 327 } catch (error) { /* fall through to the selects */ } 328 } 329 var selects = form.querySelectorAll('select[name^="attribute_"]'); 330 for (var i = 0; i < selects.length; i++) { 331 if (selects[i].options.length > 1) return true; 332 } 333 return false;
334 } 335 336 function initProductPage() { 337 var mount = document.querySelector('[data-sst-rsn-mount]'); 338 if (!mount) return; 339 var lines = {}; 340 try { 341 lines = JSON.parse(mount.dataset.lines || '{}'); 342 } catch (error) { 343 lines = {}; 344 } 345 mount.classList.add('sst-rsn-mount--ready'); 346 var releaseStartMs = parseInt(mount.dataset.releaseStartMs || '', 10) || 0; 347 348 function render(lineId) { 349 mount.innerHTML = ''; 350 var info = lineId ? lines[String(lineId)] : null; 351 if (!info || info.inStock) return; 352 var box = document.createElement('div'); 353 box.className = 'sst-rsn-product'; 354 var text = document.createElement('span'); 355 text.className = 'sst-rsn-product__text'; 356 text.textContent = t('lead', 'We will email you once this item is available again.'); 357 box.appendChild(makeButton({ 358 lineId: lineId, 359 offerType: '', 360 offerLabel: info.label || '', 361 source: 'product' 362 })); 363 box.appendChild(text); 364 mount.appendChild(box); 365 } 366 367 // The whole product at once, because there is nothing to pick between yet. 368 function renderTeaser() { 369 var multi = {}; 370 var count = 0; 371 Object.keys(lines).forEach(function (key) { 372 var info = lines[key] || {}; 373 if (info.inStock) return; 374 multi[key] = { 375 offerType: info.offerType || '', 376 offerLabel: info.offerLabel || info.label || '' 377 }; 378 count++; 379 }); 380 if (!count) return; 381 var ahead = releaseStartMs > 0 && Date.now() < releaseStartMs; 382 mount.innerHTML = ''; 383 var box = document.createElement('div'); 384 box.className = 'sst-rsn-product sst-rsn-product--teaser'; 385 box.appendChild(makeMultiButton({ 386 lines: multi, 387 source: 'pdp-teaser', 388 productName: mount.dataset.productName || '', 389 releaseStartMs: releaseStartMs, 390 label: ahead 391 ? t('preorderNotify', 'Email me when pre-orders open') 392 : t('availableNotify', 'Notify me when available') 393 })); 394 var text = document.createElement('span'); 395 text.className = 'sst-rsn-product__text'; 396 text.textContent = ahead 397 ? t('multiLead', 'You will get one email when pre-orders open.') 398 : t('lead', 'We will email you once this item is available again.'); 399 box.appendChild(text); 400 mount.appendChild(box); 401 } 402 403 if (mount.dataset.productType !== 'variable') { 404 render(parseInt(mount.dataset.productId, 10)); 405 return; 406 } 407 408 var form = document.querySelector('form.variations_form'); 409 if (!formHasVariations(form)) { 410 renderTeaser(); 411 return; 412 } 413 if (window.jQuery) { 414 window.jQuery(form) 415 .on('show_variation', function (event, variation) { 416 render(variation && variation.variation_id ? parseInt(variation.variation_id, 10) : 0); 417 }) 418 .on('hide_variation reset_data', function () { 419 render(0); 420 }); 421 return; 422 } 423 var input = form.querySelector('input.variation_id'); 424 if (!input) return; 425 new MutationObserver(function () { 426 render(parseInt(input.value, 10)); 427 }).observe(input, { attributes: true, attributeFilter: ['value'] }); 428 } 429 430 // ---- dialog ------------------------------------------------------------ 431 432 var dialog = null; 433 434 function closeDialog() { 435 if (dialog) { 436 dialog.remove(); 437 dialog = null; 438 } 439 } 440 441 function openDialog(button) { 442 closeDialog(); 443 dialog = document.createElement('div'); 444 dialog.className = 'sst-rsn-dialog'; 445 dialog.innerHTML = 446 '<div class="sst-rsn-dialog__backdrop" data-rsn-close="1"></div>' 447 + '<div class="sst-rsn-dialog__panel" role="dialog" aria-modal="true">' 448 + '<h3 class="sst-rsn-dialog__title"></h3>' 449 + '<p class="sst-rsn-dialog__lead"></p>' 450 + '<label class="sst-rsn-dialog__label" for="sst-rsn-email"></label>' 451 + '<input class="sst-rsn-dialog__input" id="sst-rsn-email" type="email" autocomplete="email" inputmode="email" spellcheck="false">' 452 + '<label class="sst-rsn-dialog__check"><input type="checkbox" id="sst-rsn-news" checked><span></span></label>' 453 + '<p class="sst-rsn-dialog__note" hidden></p>' 454 + '<p class="sst-rsn-dialog__error" hidden></p>' 455 + '<div class="sst-rsn-dialog__actions">' 456 + '<button type="button" class="sst-rsn-dialog__cancel" data-rsn-close="1"></button>' 457 + '<button type="button" class="sst-rsn-dialog__submit"></button>' 458 + '</div></div>'; 459 460 dialog.querySelector('.sst-rsn-dialog__title').textContent = t('title', 'Back-in-stock alert'); 461 // "Available again" is wrong for a product that has never been on sale, and a 462 // multi-line bell on a teaser is exactly that case. It also answers the question 463 // the reader would otherwise ask: one button, two variations, how much mail? 464 dialog.querySelector('.sst-rsn-dialog__lead').textContent = (button.dataset.multiLines && isTeaser(button)) 465 ? t('multiLead', 'You will get one email when pre-orders open.') 466 : t('lead', 'We will email you once this item is available again.'); 467 dialog.querySelector('.sst-rsn-dialog__label').textContent = t('emailLabel', 'Email address'); 468 dialog.querySelector('.sst-rsn-dialog__check span').textContent = t('newsletter', 'Also send me news and sale announcements'); 469 if (!identityLoggedIn) { 470 dialog.querySelector('.sst-rsn-dialog__check').hidden = true; 471 dialog.querySelector('#sst-rsn-news').checked = false;
472 } else if (identityNewsletterOptout) { 473 // Shown, cleared, and said out loud. Leaving it ticked for somebody who has 474 // already unsubscribed is how an unsubscribe gets undone by a customer who 475 // only wanted to be told about one card coming back in stock. 476 dialog.querySelector('#sst-rsn-news').checked = false; 477 var note = dialog.querySelector('.sst-rsn-dialog__note'); 478 note.textContent = t( 479 'newsletterOff', 480 'You unsubscribed from the newsletter. Your back-in-stock alert works either way - tick the box only if you want the newsletter again.' 481 ); 482 note.hidden = false; 483 } 484 dialog.querySelector('.sst-rsn-dialog__cancel').textContent = t('cancel', 'Cancel'); 485 dialog.querySelector('.sst-rsn-dialog__submit').textContent = t('submit', 'Notify me'); 486 487 document.body.appendChild(dialog); 488 var input = dialog.querySelector('.sst-rsn-dialog__input'); 489 if (identityKnown) { 490 // The address is already on file; show it instead of asking again. 491 dialog.querySelector('.sst-rsn-dialog__label').textContent = identityEmail; 492 input.hidden = true; 493 input.value = ''; 494 } else { 495 input.focus(); 496 } 497 var remembered = newsletterPreference(); 498 if (identityLoggedIn && !identityNewsletterOptout && remembered !== null) { 499 // A remembered '1' predates the unsubscribe on a shared browser often enough 500 // that it cannot be allowed to re-tick the box; the unsubscribe is the newer 501 // and the more deliberate of the two answers. 502 dialog.querySelector('#sst-rsn-news').checked = remembered === '1'; 503 } 504 505 dialog.addEventListener('click', function (event) { 506 if (event.target.dataset && event.target.dataset.rsnClose) closeDialog(); 507 }); 508 dialog.querySelector('.sst-rsn-dialog__submit').addEventListener('click', function () { 509 submitDialog(button); 510 }); 511 input.addEventListener('keydown', function (event) { 512 if (event.key === 'Enter') submitDialog(button); 513 }); 514 document.addEventListener('keydown', function onEsc(event) { 515 if (event.key === 'Escape') { 516 closeDialog(); 517 document.removeEventListener('keydown', onEsc); 518 } 519 }); 520 } 521 522 function dialogError(message) { 523 if (!dialog) return; 524 var element = dialog.querySelector('.sst-rsn-dialog__error'); 525 element.textContent = message; 526 element.hidden = !message; 527 } 528 529 function submitDialog(button) { 530 if (!dialog) return; 531 var email = dialog.querySelector('.sst-rsn-dialog__input').value.trim(); 532 var newsletter = dialog.querySelector('#sst-rsn-news').checked; 533 if (!identityKnown && (!email || email.indexOf('@') < 1)) { 534 dialogError(t('errorEmail', 'Please enter a valid email address.')); 535 return; 536 } 537 // Only remember an answer they were actually asked for. Storing the 538 // force-cleared value made "nothing left to ask" true forever, so the same 539 // person went out with newsletter: '0' for good once they signed in. 540 if (identityLoggedIn) rememberNewsletter(newsletter); 541 var submit = dialog.querySelector('.sst-rsn-dialog__submit'); 542 submit.disabled = true; 543 subscribe(button, email, newsletter).then(function (ok) { 544 if (ok) { 545 closeDialog(); 546 } else { 547 submit.disabled = false; 548 } 549 }); 550 } 551 552 // ---- actions ----------------------------------------------------------- 553 554 function errorMessage(status, code) { 555 if (code === 'bad_email') return t('errorEmail', 'Please enter a valid email address.'); 556 if (code === 'rate_limited' || status === 429) { 557 return code === 'too_many' ? t('errorTooMany', 'Too many watched items.') : t('errorRate', 'Too many requests. Please try again later.'); 558 } 559 return t('errorGeneric', 'Something went wrong. Please try again.'); 560 } 561 562 /** 563 * Sign this button's reader up to every line it stands for. 564 * 565 * One POST per line and one at a time, never in parallel: /watch is rate limited 566 * per caller and a burst is the shape that limiter exists to stop. Each line that 567 * lands is banked in `watched` before the next goes out, so a failure partway 568 * through leaves a button that can be pressed again to send only what is missing 569 * - and the ON state stays honest, because it needs all of them. 570 */ 571 function subscribe(button, email, newsletter) { 572 var lines = unwatchedLines(button); 573 if (!lines.length) { 574 syncAllButtons(); 575 return Promise.resolve(true); 576 } 577 button.classList.add('is-busy'); 578 var source = button.dataset.source || ''; 579 580 function send(index) { 581 if (index >= lines.length) { 582 button.classList.remove('is-busy'); 583 syncAllButtons(); 584 flash(button, t('done', 'Done â we will email you when it is back.')); 585 return true; 586 } 587 var line = lines[index]; 588 return api('/watch', { 589 method: 'POST', 590 body: { 591 line_product_id: line.lineId, 592 offer_type: line.offerType || '', 593 offer_label: line.offerLabel || '', 594 source: source, 595 email: email || undefined, 596 newsletter: newsletter ? '1' : '0' 597 } 598 }).then(function (result) { 599 if (!result.data || !result.data.ok) { 600 button.classList.remove('is-busy'); 601 var message = errorMessage(result.status, result.data && result.data.code); 602 dialogError(message); 603 if (!dialog) flash(button, message); 604 syncAllButtons(); 605 return false;
606 } 607 identityKnown = true; 608 identityEmail = result.data.email_masked || identityEmail; 609 watched.add(line.lineId); 610 return send(index + 1); 611 }); 612 } 613 614 return send(0).catch(function () { 615 button.classList.remove('is-busy'); 616 dialogError(t('errorGeneric', 'Something went wrong. Please try again.')); 617 syncAllButtons(); 618 return false; 619 }); 620 } 621 622 function unsubscribe(button) { 623 var lines = buttonLines(button).filter(function (line) { 624 return watched.has(line.lineId); 625 }); 626 if (!lines.length) return Promise.resolve(); 627 button.classList.add('is-busy'); 628 629 function send(index) { 630 if (index >= lines.length) { 631 button.classList.remove('is-busy'); 632 syncAllButtons(); 633 return; 634 } 635 var lineId = lines[index].lineId; 636 return api('/unwatch', { 637 method: 'POST', 638 body: { line_product_id: lineId } 639 }).then(function (result) { 640 if (result.data && result.data.ok) watched.delete(lineId); 641 return send(index + 1); 642 }); 643 } 644 645 return send(0).catch(function () { 646 button.classList.remove('is-busy'); 647 syncAllButtons(); 648 }); 649 } 650 651 function flash(button, message) { 652 var tip = document.createElement('span'); 653 tip.className = 'sst-rsn-flash'; 654 tip.textContent = message; 655 document.body.appendChild(tip); 656 var rect = button.getBoundingClientRect(); 657 tip.style.top = (window.scrollY + rect.top - tip.offsetHeight - 8) + 'px'; 658 tip.style.left = Math.max(8, window.scrollX + rect.left + (rect.width / 2) - (tip.offsetWidth / 2)) + 'px'; 659 setTimeout(function () { tip.remove(); }, 3200); 660 } 661 662 // ---- wiring ------------------------------------------------------------ 663 664 document.addEventListener('click', function (event) { 665 var row = event.target.closest ? event.target.closest('tr.sst-qo__row, tr[data-pname]') : null; 666 if (row) lastClickedRow = row; 667 668 var button = event.target.closest ? event.target.closest('.sst-rsn-btn') : null; 669 if (!button) return; 670 event.preventDefault(); 671 event.stopPropagation(); 672 if (button.classList.contains('is-busy')) return; 673 // A server-rendered bell can be clicked before the scan that adopts it has run. 674 // Adopting it here is what keeps it in step with the rest of the page afterwards. 675 if (button.dataset.multiLines) adopt(button); 676 if (!buttonLines(button).length) return; 677 if (buttonIsOn(button)) { 678 unsubscribe(button); 679 return; 680 } 681 var remembered = newsletterPreference(); 682 // A remembered yes belonging to somebody who has since unsubscribed is the one 683 // combination that must not be spent silently: one click would send an opt-in 684 // they have already retracted. Open the dialog so they see the box, cleared, 685 // with the reason underneath - and let them tick it if that is what they want. 686 var staleYes = identityNewsletterOptout && remembered === '1'; 687 if (identityLoaded && identityKnown && identityLoggedIn && remembered !== null && !staleYes) { 688 // A signed-in customer who has already answered the newsletter question: one 689 // click is enough. Guests keep the dialog, because it is the only place the 690 // address on file is shown - and on a shared machine the cookie is the last 691 // visitor's, not this one's. 692 subscribe(button, '', remembered === '1' && !identityNewsletterOptout).then(function (ok) { 693 if (!ok) openDialog(button); 694 }); 695 return; 696 } 697 openDialog(button); 698 }, true); 699 700 var scheduled = false; 701 function scheduleScan() { 702 if (scheduled) return; 703 scheduled = true; 704 // setTimeout, not requestAnimationFrame: quick-order.js rebuilds rows while the 705 // tab can be in the background, where rAF callbacks never run. 706 window.setTimeout(function () { 707 scheduled = false; 708 injectQuickOrder(document); 709 injectModal(); 710 adoptRendered(document); 711 syncAllButtons(); 712 }, 60); 713 } 714 715 function start() { 716 loadIdentity(); 717 injectQuickOrder(document); 718 initProductPage(); 719 adoptRendered(document); 720 new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true }); 721 // Quick Order renders lazy groups after load; catch whatever the observer 722 // may have coalesced away before the table settled.
723 [400, 1500, 4000].forEach(function (delay) { 724 window.setTimeout(scheduleScan, delay); 725 }); 726 } 727 728 if (document.readyState === 'loading') { 729 document.addEventListener('DOMContentLoaded', start); 730 } else { 731 start(); 732 } 733})();
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.