PageSourceSearch

https://samuraiswordtokyo.com/wp-content/plugins/sst-restock-notif…/restock-notify.perf2.js?ver=0.10.0.perf2

js samuraiswordtokyo.com collected 2026-10-06 04:17:23 UTC 29,500 bytes, 733 lines download raw bytes

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.