PageSourceSearch

https://its.co.uk/content/campaigns/redemptions.js

js its.co.uk collected 2026-10-04 00:06:36 UTC 13,199 bytes, 283 lines download raw bytes

1/* ITS brand redemptions: one list, two pages.
2   Reads /content/campaigns/redemptions.json and renders it into any element
3   carrying data-redemptions="carousel" (home page slider) or
4   data-redemptions="grid" (the Free Stuff page). Promos outside their
5   start/end window are dropped on both pages at the same minute. If nothing
6   is live, or the file will not load, the surrounding <section> is hidden
7   rather than showing an empty band.
8   Upload this file to /content/campaigns/redemptions.js. */
9(function () {
10  /* ONCE PER PAGE. Both storefront blocks carry their own script tag so
11     each works on its own; on a page with both, the script arrives twice
12     and the second run must not rebuild the carousel on top of the first
13     (that doubles the arrow handlers and the dots). Each mount is also
14     marked when done, so a later run can only fill mounts it has not seen. */
15  if (window.ITSRedemptions) return;
16  window.ITSRedemptions = { v: 3 };
17  var SRC = window.ITS_REDEMPTIONS_SRC || '/content/campaigns/redemptions.json';
18
19  function parseLocal(v) {
20    if (!v) return null;
21    var p = String(v).trim().split('T');
22    if (p.length !== 2) return null;
23    var d = p[0].split('-').map(Number), t = p[1].split(':').map(Number);
24    var dt = new Date(d[0], (d[1] || 1) - 1, d[2] || 1, t[0] || 0, t[1] || 0, 0, 0);
25    return isNaN(dt.getTime()) ? null : dt;
26  }
27  function isLive(pr, now) {
28    var s = parseLocal(pr.start), e = parseLocal(pr.end);
29    if (e) e = new Date(e.getTime() + 59 * 1000);
30    return (!s || now >= s) && (!e || now <= e);
31  }
32  /* ORDER. First in, first out by start date: the promo that went live
33     first shows first. An optional "order" number on a promo overrides
34     that: anything numbered comes before anything unnumbered, lowest
35     number first. Two promos with the same number (a mistake, but it will
36     happen) fall back to first in, first out between themselves, and a
37     dead heat on start date keeps the JSON order. So a file with no
38     numbers at all still orders itself sensibly, and one number on one
39     promo is all it takes to pin it to the front. */
40  function orderOf(pr) {
41    var v = pr.order == null || pr.order === '' ? NaN : Number(pr.order);
42    return isFinite(v) ? v : null;
43  }
44  function startOf(pr) { var d = parseLocal(pr.start); return d ? d.getTime() : 0; }
45  function sortPromos(list) {
46    return list.map(function (p, i) { return { p: p, i: i }; }).sort(function (a, b) {
47      var ao = orderOf(a.p), bo = orderOf(b.p);
48      if (ao !== bo) {
49        if (ao === null) return 1;
50        if (bo === null) return -1;
51        return ao - bo;
52      }
53      var as = startOf(a.p), bs = startOf(b.p);
54      if (as !== bs) return as - bs;
55      return a.i - b.i;
56    }).map(function (x) { return x.p; });
57  }
58
59  function esc(s) {
60    return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
61      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
62    });
63  }
64  function validUntil(pr) {
65    var d = parseLocal(pr.end);
66    if (!d) return '';
67    var n = d.getDate();
68    var s = (n % 10 === 1 && n !== 11) ? 'st' : (n % 10 === 2 && n !== 12) ? 'nd' : (n % 10 === 3 && n !== 13) ? 'rd' : 'th';
69    var m = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][d.getMonth()];
70    return 'Valid until ' + n + s + ' ' + m + ' ' + d.getFullYear();
71  }
72
73  /* Home page: image-only slide. Wide art from 768px up, square below. */
74  function carouselItem(pr, clone) {
75    return '<div class="redem-slide' + (clone ? ' redem-slide--clone" aria-hidden="true"' : '"') + '><div class="redem-card">' +
76      '<a href="' + esc(pr.href) + '"><picture>' +
77      '<source media="(min-width: 768px)" srcset="' + esc(pr.img.wide || pr.img.square) + '">' +
78      '<img class="redem-img" loading="lazy" src="' + esc(pr.img.square) + '" alt="' + esc(pr.alt || pr.title) + '">' +
79      '</picture></a></div></div>';
80  }
81
82  /* Free Stuff page: art, caption, More Info button, valid-until line.
83     img.list is the tall listing art; falls back to the square. */
84  function gridItem(pr) {
85    return '<div class="redem-tile">' +
86      '<a class="redem-tile__img" href="' + esc(pr.href) + '">' +
87      '<img loading="lazy" src="' + esc(pr.img.list || pr.img.square) + '" alt="' + esc(pr.alt || pr.title) + '"></a>' +
88      '<p class="redem-tile__title">' + esc(pr.title) + '</p>' +
89      '<a class="redem-tile__btn" href="' + esc(pr.href) + '">More Info</a>' +
90      '<p class="redem-tile__valid">' + esc(validUntil(pr)) + '</p>' +
91      '</div>';
92  }
93
94  /* Free Stuff page layout: fixed-size cards, centred rows, pyramid.
95     The card is the constant (its width is --redem-card in the block's CSS)
96     and never stretches to fill the band, so 3, 4 and 5 promos all show
97     the same size card, just centred. Up to MAX_ACROSS per row; past that
98     the promos split as evenly as possible over the fewest rows with the
99     SMALLER row on top, so 7 is 3 over 4, 9 is 4 over 5, 11 is 3, 4, 4.
100     Promos listed first in the JSON take the top row. */
101  var MAX_ACROSS = 5;
102  function rowSizes(n) {
103    if (n <= 0) return [];
104    var rows = Math.ceil(n / MAX_ACROSS), base = Math.floor(n / rows), extra = n % rows, out = [];
105    for (var r = 0; r < rows; r++) out.push(base + (r >= rows - extra ? 1 : 0));
106    return out;
107  }
108  function gridRows(promos) {
109    var i = 0;
110    return rowSizes(promos.length).map(function (size) {
111      var r = promos.slice(i, i + size); i += size;
112      return '<div class="redem-row redem-row--' + size + '">' + r.map(gridItem).join('') + '</div>';
113    }).join('');
114  }
115
116  /* THE HOME CAROUSEL IS NATIVE. No slick, no jQuery. The mount is a
117     scroll-snap track (CSS in the home block); the arrows scroll it one
118     card at a time and wrap at either end; swipe works because it is a
119     real scroller; dots are built here and shown by CSS on phones only.
120     It used to run slick, and that broke in ways nobody could see from
121     the code: the page loads more than one jQuery, slick attached itself
122     to the first, the theme replaced window.jQuery with a later one, and
123     the slider silently never initialised; and once that was fixed, the
124     theme's own carousel initialiser matched the old its-home-* classes and
125     ran slick on top of this slider after it had rendered. So: no
126     dependency, and every class the block uses is redem-*, a name the
127     theme does not know and cannot grab. */
128  function initCarousel(mount, promos) {
129    /* anything slick left on the mount from an earlier life */
130    mount.className = mount.className.replace(/\bslick-\S+/g, '').replace(/\s+/g, ' ').trim();
131    var root = mount.closest('.redem-carousel') || mount.parentNode;
132    var prev = root.querySelector('.redem-arrow--prev');
133    var next = root.querySelector('.redem-arrow--next');
134    var n = promos.length, K = 0, loop = false, lastShow = 0, timer = null;
135
136    /* INFINITE, THE CLONE WAY. When there are more promos than fit across,
137       K copies of the last promos go in front and K copies of the first go
138       behind (K = how many fit across), the track starts on the first real
139       one, and whenever a scroll settles inside a clone zone it is moved by
140       one full set, instantly, to the matching real slide. The eye never
141       sees the jump because the pixels are identical either side of it. If
142       everything fits, there is nothing to scroll: no clones, arrows off. */
143    function showNow() {
144      var v = parseInt(getComputedStyle(mount).getPropertyValue('--redem-show'), 10);
145      return v > 0 ? v : 4;
146    }
147    function at(i) { return promos[((i % n) + n) % n]; }
148    /* Positions come from the slides themselves, not from width + gap:
149       the slide carries its gap as padding, so slide N starts at exactly
150       N slide-widths and that is also its snap point. */
151    function step() {
152      var it = mount.querySelector('.redem-slide');
153      return it ? it.getBoundingClientRect().width : 0;
154    }
155    function idx() { var s = step(); return s ? Math.round(mount.scrollLeft / s) : 0; }
156    function jump(i) { mount.scrollTo({ left: i * step(), behavior: 'auto' }); }
157    function realIndex(i) { return ((i - K) % n + n) % n; }
158
159    var dots = document.createElement('ul');
160    dots.className = 'redem-dots';
161    for (var d = 0; d < n; d++) {
162      (function (d) {
163        var li = document.createElement('li'), b = document.createElement('button');
164        b.type = 'button'; b.setAttribute('aria-label', 'Slide ' + (d + 1));
165        b.addEventListener('click', function () { mount.scrollTo({ left: (K + d) * step(), behavior: 'smooth' }); });
166        li.appendChild(b); dots.appendChild(li);
167      })(d);
168    }
169    mount.parentNode.insertBefore(dots, mount.nextSibling);
170
171    function mark() {
172      var real = realIndex(idx());
173      var bs = dots.querySelectorAll('button');
174      for (var i = 0; i < bs.length; i++) bs[i].classList.toggle('is-active', i === real);
175    }
176    function settle() {
177      if (!loop) return;
178      var i = idx();
179      if (i < K) jump(i + n);
180      else if (i >= K + n) jump(i - n);
181      mark();
182    }
183    function go(dir) {
184      if (!loop) return;
185      mount.scrollBy({ left: dir * step(), behavior: 'smooth' });
186    }
187    function build() {
188      var show = showNow();
189      lastShow = show;
190      loop = n > show;
191      K = loop ? show : 0;
192      var html = '';
193      for (var i = -K; i < n + K; i++) html += carouselItem(at(i), i < 0 || i >= n);
194      mount.innerHTML = html;
195      if (prev) prev.classList.toggle('is-disabled', !loop);
196      if (next) next.classList.toggle('is-disabled', !loop);
197      jump(K);
198      mark();
199    }
200
201    if (prev) prev.addEventListener('click', function () { go(-1); });
202    if (next) next.addEventListener('click', function () { go(1); });
203
204    /* MOUSE DRAG. Touch scrolls the track natively; a mouse does not, so
205       drag it by hand: pointerdown records where we are, pointermove moves
206       the track by the mouse delta with snapping switched off, pointerup
207       switches snapping back on and eases to the nearest card. A drag must
208       not open the card link under the pointer, so the click that ends a
209       real drag (more than a few pixels) is swallowed once. */
210    var drag = null;
211    mount.addEventListener('pointerdown', function (e) {
212      if (e.pointerType !== 'mouse' || e.button !== 0) return;
213      drag = { x: e.clientX, left: mount.scrollLeft, moved: false };
214      mount.classList.add('is-dragging');
215    });
216    window.addEventListener('pointermove', function (e) {
217      if (!drag) return;
218      var dx = e.clientX - drag.x;
219      if (Math.abs(dx) > 4) drag.moved = true;
220      mount.scrollLeft = drag.left - dx;
221    });
222    function endDrag() {
223      if (!drag) return;
224      var moved = drag.moved;
225      drag = null;
226      mount.classList.remove('is-dragging');
227      var s = step();
228      if (s) mount.scrollTo({ left: Math.round(mount.scrollLeft / s) * s, behavior: 'smooth' });
229      if (moved) {
230        var swallow = function (ev) { ev.preventDefault(); ev.stopPropagation(); mount.removeEventListener('click', swallow, true); };
231        mount.addEventListener('click', swallow, true);
232        setTimeout(function () { mount.removeEventListener('click', swallow, true); }, 300);
233      }
234    }
235    window.addEventListener('pointerup', endDrag);
236    window.addEventListener('pointercancel', endDrag);
237    mount.addEventListener('dragstart', function (e) { e.preventDefault(); });
238
239    mount.addEventListener('scroll', function () {
240      mark();
241      clearTimeout(timer);
242      timer = setTimeout(settle, 120);
243    }, { passive: true });
244    if ('onscrollend' in window) mount.addEventListener('scrollend', settle);
245    var rt = null;
246    window.addEventListener('resize', function () {
247      clearTimeout(rt);
248      rt = setTimeout(function () {
249        if (showNow() !== lastShow) build(); else { jump(idx()); mark(); }
250      }, 150);
251    });
252    build();
253  }
254
255  function hideSection(mount) {
256    var sec = mount.closest ? mount.closest('section') : null;
257    if (sec) sec.style.display = 'none'; else mount.style.display = 'none';
258  }
259
260  function render(promos) {
261    var mounts = document.querySelectorAll('[data-redemptions]:not([data-redemptions-ready])');
262    Array.prototype.forEach.call(mounts, function (mount) {
263      mount.setAttribute('data-redemptions-ready', '1');
264      var mode = mount.getAttribute('data-redemptions');
265      if (!promos.length) { hideSection(mount); return; }
266      if (mode === 'carousel') { initCarousel(mount, promos); return; }
267      mount.innerHTML = gridRows(promos);
268    });
269  }
270
271  function go() {
272    fetch(SRC, { cache: 'no-cache' })
273      .then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
274      .then(function (d) {
275        var now = new Date();
276        render(sortPromos((d.promos || []).filter(function (p) {
277          return p && p.href && p.img && p.img.square && isLive(p, now);
278        })));
279      })
280      .catch(function () { render([]); });
281  }
282  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go); else go();
283})();

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.