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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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.