PageSourceSearch

https://snipershopping.com/shared/js/shipping.js

js snipershopping.com collected 2026-10-06 10:32:08 UTC 14,781 bytes, 374 lines download raw bytes

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.