1/* Sniper - shipping: the free-delivery promise, and the rate engine. 2 3 THE PROMISE 4 The free-delivery threshold is one number, set once in the site's config - 5 which is what Settings > Shipping writes - and every place that quotes it 6 reads from there: the bar above the header, the hero strip, the footer, the 7 product page, the category assurances, the meta descriptions. 8 9 It matters that this is one number rather than a sentence typed into six 10 pages. The threshold the site *promises* and the threshold the rate table 11 *charges against* have to be the same figure, or the site advertises free 12 delivery at 25 and the basket charges for it at 30. Same value, both places - 13 the shipping admin flags any home-country method whose free-over disagrees. 14 15 In the built pages the amount arrives as <b data-bhd="25">BHD 25</b>, so the 16 currency switcher rewrites it along with every other price - a customer 17 shopping in SAR sees the threshold in SAR. 18 19 Set freeOver to null and the promise is switched off: every element marked 20 data-ship="promise" is removed rather than left saying something untrue. 21 22 THE RATE ENGINE 23 Rates live in shared/js/shipping-rates.js (see the shape documented there). 24 Everything that shows a delivery price - the product page, the basket, the 25 admin's preview table and rate tester - calls quote() below, so the maths 26 exists once and the admin can never preview a number the site won't
26charge. 27 28 The customer's destination is remembered ("sniper.shipto.v1"). With nothing 29 saved it follows the display currency (SAR -> Saudi Arabia, and so on), then 30 falls back to the home country. 31 32 Admin preview: the shipping admin can save a draft into this browser 33 ("sniper.shipping.draft.v1"). When one exists it replaces the published rates 34 here, and a small banner says so - it only ever affects that one browser. */ 35(function () { 36 "use strict"; 37 38 var S = window.SNIPER_SHIPPING || {}; 39 var over = (S.freeOver === 0 || S.freeOver) ? Number(S.freeOver) : null; 40 41 /* the string the translations interpolate into {amount} */ 42 window.SNIPER_I18N_VARS = window.SNIPER_I18N_VARS || {}; 43 if (over != null) { 44 window.SNIPER_I18N_VARS.amount = 45 '<b data-bhd="' + over + '">BHD ' + over.toFixed(over % 1 ? 3 : 0) + "</b>"; 46 } else { 47 window.SNIPER_I18N_VARS.amount = ""; 48 } 49 50 /* ------------------------------------------------------------- storage */ 51 var SHIPTO_KEY = "sniper.shipto.v1"; 52 var DRAFT_KEY = "sniper.shipping.draft.v1"; 53 54 function load(key) { 55 try { return localStorage.getItem(key); } catch (err) { return null; } 56 } 57 function save(key, value) { 58 try { 59 if (value == null) localStorage.removeItem(key); 60 else localStorage.setItem(key, value); 61 } catch (err) { /* storage blocked - choice lasts for this page only */ } 62 } 63 64 var RATES = window.SNIPER_SHIPPING_RATES || {}; 65 var draft = false; 66 (function () { 67 var raw = load(DRAFT_KEY); 68 if (!raw) return; 69 try { 70 var d = JSON.parse(raw); 71 if (d && (d.shopping || d.safety)) { RATES = d; draft = true; } 72 } catch (err) { /* corrupt draft - ignore it */ } 73 })(); 74 75 /* ------------------------------------------------------------ countries */ 76 var COUNTRIES = { 77 BH: { en: "Bahrain", ar: "Ø§ÙØ¨ØØ±ÙÙ" }, 78 SA: { en: "Saudi Arabia", ar: "Ø§ÙØ³Ø¹ÙØ¯ÙØ©" }, 79 AE: { en: "United Arab Emirates", ar: "Ø§ÙØ¥Ù ارات" }, 80 KW: { en: "Kuwait", ar: "اÙÙÙÙØª" }, 81 QA: { en: "Qatar", ar: "ÙØ·Ø±" }, 82 OM: { en: "Oman", ar: "عÙ٠اÙ" }, 83 IQ: { en: "Iraq", ar: "Ø§ÙØ¹Ø±Ø§Ù" }, 84 JO: { en: "Jordan", ar: "Ø§ÙØ£Ø±Ø¯Ù" }, 85 EG: { en: "Egypt", ar: "٠صر" }, 86 LB: { en: "Lebanon", ar: "ÙØ¨ÙاÙ" }, 87 YE: { en: "Yemen", ar: "اÙÙÙ Ù" }, 88 TR: { en: "Turkey", ar: "ترÙÙØ§" }, 89 IN: { en: "India", ar: "اÙÙÙØ¯" }, 90 PK: { en: "Pakistan", ar: "Ø¨Ø§ÙØ³ØªØ§Ù" }, 91 GB: { en: "United Kingdom", ar: "اÙÙ Ù ÙÙØ© اÙÙ ØªØØ¯Ø©" }, 92 US: { en: "United States", ar: "اÙÙÙØ§Ùات اÙÙ ØªØØ¯Ø©" } 93 }; 94 var CURRENCY_COUNTRY = { SAR: "SA", AED: "AE", KWD: "KW", QAR: "QA", OMR: "OM" }; 95 96 function lang() { 97 return window.sniperI18n && window.sniperI18n.lang === "ar" ? "ar" : "en"; 98 } 99 function t(key, en) { 100 return window.sniperI18n ? window.sniperI18n.t(key, en) : en; 101 } 102 function countryName(code) { 103 var c = COUNTRIES[code]; 104 return c ? c[lang()] : code; 105 } 106 function home() { return RATES.home || "BH"; } 107 108 function division() { 109 var b = document.body; 110 if (b && b.dataset.division) return b.dataset.division; 111 return /safety/i.test(location.pathname) ? "safety" : "shopping"; 112 } 113 function divisionRates(div) { 114 return RATES[div || division()] || { destinations: [] }; 115 } 116 117 /* the countries a customer can pick: every one named in any destination, 118 plus the full list when a destination covers "everywhere else" */ 119 function shippable(div) { 120 var list = [], wildcard = false; 121 divisionRates(div).destinations.forEach(function (d) { 122 (d.countries || []).forEach(function (c) { 123 if (c === "*") wildcard = true; 124 else if (list.indexOf(c) < 0) list.push(c); 125 }); 126 }); 127 if (wildcard) { 128 Object.keys(COUNTRIES).forEach(function (c) { if (list.indexOf(c) < 0) list.push(c); }); 129 } 130 return list; 131 } 132 133 function country() { 134 var saved = load(SHIPTO_KEY); 135 if (saved && COUNTRIES[saved]) return saved; 136 /* where the visitor is (shared/js/geo.js) - a Saudi visitor gets Saudi rates */ 137 var geo = window.SNIPER_GEO || {}; 138 if (geo.country && COUNTRIES[geo.country]) return geo.country; 139 var cur = load("sniper.currency.v1"); 140 if (cur && CURRENCY_COUNTRY[cur]) return CURRENCY_COUNTRY[cur]; 141 return home(); 142 } 143 function setCountry(code) { 144 if (!COUNTRIES[code]) return; 145 save(SHIPTO_KEY, code); 146 document.dispatchEvent(new CustomEvent("sniper:shipto", { detail: { country: code } })); 147 } 148 149 /* first destination naming the country wins; "*" catches the rest */ 150 function destination(div, code) { 151 var ds = divisionRates(div).destinations || []; 152 var hit = ds.filter(function (d) { return (d.countries || []).indexOf(code) >= 0; })[0]; 153 return hit || ds.filter(function (d) { return (d.countries || []).indexOf("*") >= 0; })[0] || null; 154 } 155 156 /* ---------------------------------------------------------------- maths */ 157 function num(v) { 158 return v === "" || v == null || isNaN(Number(v)) ? null : Number(v); 159 } 160 function r3(n) { return Math.round(n * 1000) / 1000; } 161 162 /* price of one method for a basket: { price, free, quoted, available } */ 163 function price(m, kg, subtotal) { 164 kg = Math.max(0, Number(kg) || 0); 165 var maxKg = num(m.maxKg); 166 if (maxKg != null && kg > maxKg) return { available: false, reason: "weight" }; 167 if (m.pricing === "quoted") return { available: true, quoted: true, price: null }; 168 169 var p = num(m.base) || 0; 170 if (m.pricing === "formula") { 171 var step = num(m.stepKg); 172 var extra = Math.max(0, kg - (num(m.firstKg) || 0)); 173 /* toFixed keeps 1.5 - 1 = 0.5000000001 from counting as a second step */ 174 var steps = step > 0 ? Math.ceil(Number((extra / step).toFixed(6))) : 0; 175 p += steps * (num(m.perStep) || 0); 176 } 177 var min = num(m.min), max = num(m.max); 178 if (min != null && p < min) p = min; 179 if (max != null && p > max) p = max; 180 181 var freeOver = num(m.freeOver); 182 if (freeOver != null && Number(subtotal || 0) >= freeOver) { 183 return { available: true, free: true, price: 0, calculated: r3(p) }; 184 } 185 return { available: true, free: p === 0, price: r3(p) }; 186 } 187 188 /* ------------------------------------------------------------ estimates */ 189 function methodName(m) { 190 if (!m || !m.name) return ""; 191 if (typeof m.name === "string") return m.name; 192 return (lang() === "ar" && m.name.ar) ? m.name.ar : (m.name.en || m.name.ar || ""); 193 } 194 195 function etaText(m) { 196 var a = num(m.etaMin), b = num(m.etaMax); 197 if (a == null && b == null) return ""; 198 if (a == null) a = b; 199 if (b == null || b < a) b = a; 200 if (b === 0) return t("ship.sameday", "Same day"); 201 var ar = lang() === "ar"; 202 var working = m.etaUnit !== "calendar"; 203 var unit = ar 204 ? (working ? "Ø£ÙØ§Ù ع٠Ù" : "Ø£ÙØ§Ù ") 205 : (working ? (b === 1 ? "working day" : "working days") : (b === 1 ? "day" : "days")); 206 return (a === b ? b : a + "â" + b) + " " + unit; 207 } 208 209 /* add n days to a date, skipping the weekend when counting working days */ 210 function addDays(from, n, working, weekend) { 211 var d = new Date(from.getTime()); 212 var off = weekend || []; 213 while (working && off.indexOf(d.getDay()) >= 0) d.setDate(d.getDate() + 1); 214 var left = n; 215 while (left > 0) { 216 d.setDate(d.getDate() + 1); 217 if (working && off.indexOf(d.getDay()) >= 0) continue; 218 left -= 1; 219 } 220 return d; 221 } 222
223 /* arrival window as dates: { from: Date, to: Date } */ 224 function arrival(m, div, now) { 225 var a = num(m.etaMin), b = num(m.etaMax); 226 if (a == null && b == null) return null; 227 if (a == null) a = b; 228 if (b == null || b < a) b = a; 229 var cfg = divisionRates(div); 230 var start = new Date((now || new Date()).getTime()); 231 var cutoff = num(cfg.cutoffHour); 232 if (cutoff != null && start.getHours() >= cutoff) start.setDate(start.getDate() + 1); 233 var working = m.etaUnit !== "calendar"; 234 return { 235 from: addDays(start, a, working, cfg.weekend), 236 to: addDays(start, b, working, cfg.weekend) 237 }; 238 } 239 function fmtDate(d) { 240 try { 241 return d.toLocaleDateString(lang() === "ar" ? "ar-BH-u-nu-latn" : "en-GB", 242 { weekday: "short", day: "numeric", month: "short" }); 243 } catch (err) { return d.toDateString(); } 244 } 245 function arrivalText(m, div, now) { 246 var w = arrival(m, div, now); 247 if (!w) return ""; 248 var a = fmtDate(w.from), b = fmtDate(w.to); 249 return t("ship.arrives", "Arrives") + " " + (a === b ? a : a + " â " + b); 250 } 251 252 /* every method that applies to this basket, in the admin's order */ 253 function quote(div, code, kg, subtotal) { 254 var dest = destination(div, code); 255 if (!dest) return { destination: null, methods: [] }; 256 var out = []; 257 (dest.methods || []).forEach(function (m) { 258 if (m.active === false) return; 259 var r = price(m, kg, subtotal); 260 if (!r.available) return; 261 out.push({ 262 id: m.id, method: m, name: methodName(m), 263 price: r.price, free: !!r.free, quoted: !!r.quoted, 264 freeOver: num(m.freeOver), 265 eta: etaText(m), arrives: arrivalText(m, div) 266 }); 267 }); 268 return { destination: dest, methods: out }; 269 } 270 271 /* the formula as a sentence - the admin shows it under every method */ 272 function describe(m) { 273 var bhd = function (n) { return "BHD " + Number(n || 0).toFixed(3); }; 274 var s; 275 if (m.pricing === "quoted") s = "Quoted after the order - no price at checkout"; 276 else if (m.pricing === "flat") s = num(m.base) ? bhd(m.base) + " whatever the weight" : "Free whatever the weight"; 277 else { 278 s = bhd(m.base) + " covers the first " + (num(m.firstKg) || 0) + " kg, then " + 279 bhd(m.perStep) + " for every extra " + (num(m.stepKg) || 0) + " kg (or part of it)"; 280 } 281 var extra = []; 282 if (m.pricing !== "quoted") { 283 if (num(m.min) != null) extra.push("never below " + bhd(m.min)); 284 if (num(m.max) != null) extra.push("never above " + bhd(m.max)); 285 if (num(m.freeOver) != null) extra.push("free once the order reaches " + bhd(m.freeOver)); 286 } 287 if (num(m.maxKg) != null) extra.push("not offered above " + m.maxKg + " kg"); 288 return s + (extra.length ? "; " + extra.join("; ") : "") + "."; 289 } 290 291 /* ------------------------------------------------------------- the page */ 292 function draftBanner() { 293 if (!draft || document.body.dataset.admin || document.getElementById("shipDraft")) return; 294 var b = document.createElement("div"); 295 b.id = "shipDraft"; 296 b.className = "shipdraft"; 297 b.innerHTML = "<span>Previewing <b>draft shipping rates</b> from the admin</span>" + 298 '<button type="button" class="js-ship-discard">Discard</button>'; 299 document.body.appendChild(b); 300 } 301 302 function apply() { 303 draftBanner(); 304 if (over != null) { 305 if (window.sniperCurrency) window.sniperCurrency.paint(); 306 return; 307 } 308 /* no threshold set - take the claim off the page entirely */ 309 document.querySelectorAll('[data-ship="promise"]').forEach(function (n) { 310 n.parentNode.removeChild(n); 311 }); 312 } 313 314 /* any <select class="js-shipto"> anywhere is a destination picker */ 315 function countryOptions(div) { 316 var cur = country(); 317 var list = shippable(div); 318 if (list.indexOf(cur) < 0) list = [cur].concat(list); 319 return list.map(function (c) { 320 return '<option value="' + c + '"' + (c === cur ? " selected" : "") + ">" + 321 countryName(c) + "</option>"; 322 }).join(""); 323 } 324 document.addEventListener("change", function (e) {
325 var s = e.target.closest && e.target.closest(".js-shipto"); 326 if (s) setCountry(s.value); 327 }); 328 document.addEventListener("click", function (e) { 329 if (!e.target.closest || !e.target.closest(".js-ship-discard")) return; 330 save(DRAFT_KEY, null); 331 location.reload(); 332 }); 333 334 window.sniperShipping = { 335 freeOver: over, 336 /* "are we there yet" - for the basket, and for anything that needs to 337 know how far short an order is */ 338 shortfall: function (subtotalBhd) { 339 if (over == null) return null; 340 var gap = over - Number(subtotalBhd || 0); 341 return gap > 0 ? gap : 0; 342 }, 343 get rates() { return RATES; }, 344 get draft() { return draft; }, 345 countries: COUNTRIES, 346 countryName: countryName, 347 countryOptions: countryOptions, 348 shippable: shippable, 349 division: division, 350 divisionRates: divisionRates, 351 /* which delivery a cart line belongs to: its division, or - with "Same rates 352 for both sites" - the one delivery for the whole cart (Shipping::bucket) */ 353 bucket: function (div) { 354 return RATES.shared && (RATES.sharedFrom === "safety" || RATES.sharedFrom === "shopping") ? RATES.sharedFrom : div; 355 }, 356 country: country, 357 setCountry: setCountry, 358 destination: destination, 359 price: price, 360 quote: quote, 361 describe: describe, 362 methodName: methodName, 363 etaText: etaText, 364 arrival: arrival, 365 arrivalText: arrivalText, 366 DRAFT_KEY: DRAFT_KEY, 367 /* the admin points the engine at the rates being edited, so its preview 368 table and tester run exactly the maths the storefront will */ 369 use: function (rates) { RATES = rates || {}; } 370 }; 371 372 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", apply); 373 else apply(); 374})();
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.