PageSourceSearch

https://shipleyfarmsbeef.com/assets/shipley_checkout/js/guest_cart.js?v=29943ba037

js shipleyfarmsbeef.com collected 2026-10-07 00:36:58 UTC 13,571 bytes, 368 lines download raw bytes

1/* Anonymous guest cart (PL-GUESTCART).
2 *
3 * A guest's cart lives in this browser until they ask to check out. Nothing is written to
4 * the server, no account is created, and no email is demanded, until the one moment the
5 * shopper has actually committed to buying.
6 *
7 * WHY: measured on production 2026-09-29, every outside shopper who reached the
8 * add-to-cart email gate abandoned -- 5 gave an email, 4 got a cart, 0 ordered, $403.78
9 * stranded. See docs/FINDING_web_checkout_zero_conversion.md.
10 *
11 * WHY NOT A SERVER CART: webshop's update_cart redirects Guest away because an ERPNext
12 * Quotation needs a party, and 71% of this site's traffic is bots. A server-side guest
13 * cart would mint a Quotation and a party per bot.
14 *
15 * FAIL-SAFE BY CONSTRUCTION. Every entry point checks `window.shipleyGuestCart` exists
16 * before using it, so if this file fails to load, the PDP falls straight back to the
17 * express-account modal that ships today. Every localStorage access is wrapped: in a
18 * private window, with site data blocked, or during a thumbnail capture, reads return
19 * empty and writes are dropped rather than throwing.
20 *
21 * SERVER IS AUTHORITATIVE ON PRICE. This file stores item_code, qty and mode only. It
22 * never stores or sends a price. Everything shown on the cart page comes from
23 * guest_cart.preview_guest_cart, and the real charge is computed again at checkout.
24 */
25(function () {
26  "use strict";
27
28  var KEY = "sf_guest_cart_v1";
29  var MAX_LINES = 50;
30
31  function isGuest() {
32    try {
33      if (window.frappe && frappe.session && frappe.session.user) {
34        return frappe.session.user === "Guest";
35      }
36      if (window.frappe && frappe.get_cookie) {
37        return frappe.get_cookie("user_id") === "Guest";
38      }
39    } catch (e) { /* fall through */ }
40    // No frappe boot on this page: assume signed-out rather than risk hijacking a
41    // logged-in shopper's real cart.
42    return true;
43  }
44
45  function read() {
46    try {
47      var raw = window.localStorage.getItem(KEY);
48      if (!raw) return [];
49      var parsed = JSON.parse(raw);
50      return Array.isArray(parsed) ? parsed.slice(0, MAX_LINES) : [];
51    } catch (e) {
52      return [];
53    }
54  }
55
56  function write(lines) {
57    try {
58      if (!lines || !lines.length) {
59        window.localStorage.removeItem(KEY);
60      } else {
61        window.localStorage.setItem(KEY, JSON.stringify(lines.slice(0, MAX_LINES)));
62      }
63      return true;
64    } catch (e) {
65      // Quota, private mode, blocked site data. The add is lost, which is bad, but a
66      // thrown exception here would break the product page, which is worse.
67      return false;
68    }
69  }
70
71  function totalQty(lines) {
72    var n = 0;
73    (lines || read()).forEach(function (l) { n += Number(l.qty) || 0; });
74    return Math.round(n * 100) / 100;
75  }
76
77  /* Enabled on a PDP only when the server rendered the marker, which it does only when
78   * Shipley Checkout Settings.enable_guest_cart is on AND the visitor is a guest. */
79  function enabled() {
80    return isGuest() && !!document.querySelector("[data-sf-guest-cart]");
81  }
82
83  function add(line) {
84    if (!line || !line.item_code) return false;
85    var lines = read();
86    var existing = null;
87    for (var i = 0; i < lines.length; i++) {
88      if (lines[i].item_code === line.item_code) { existing = lines[i]; break; }
89    }
90    // ONE MODE PER ITEM PER CART, matching pdp.update_cart_line: webshop keys cart rows by
91    // item_code alone, so two rows of the same SKU in different UOMs collide in its own
92    // cart edit handlers. Switching mode rewrites the row instead of adding a second.
93    if (existing) {
94      if (existing.mode === line.mode) {
95        existing.qty = (Number(existing.qty) || 0) + (Number(line.qty) || 0);
96      } else {
97        existing.qty = Number(line.qty) || 0;
98        existing.mode = line.mode;
99      }
100      existing.subscribe_weeks = Number(line.subscribe_weeks) || 0;
101    } else {
102      if (lines.length >= MAX_LINES) return false;
103      lines.push({
104        item_code: line.item_code,
105        qty: Number(line.qty) || 0,
106        mode: line.mode || "piece",
107        subscribe_weeks: Number(line.subscribe_weeks) || 0
108      });
109    }
110    return write(lines);
111  }
112
113  function setQty(itemCode, qty) {
114    var lines = read().filter(function (l) {
115      return !(l.item_code === itemCode && Number(qty) <= 0);
116    });
117    lines.forEach(function (l) {
118      if (l.item_code === itemCode) l.qty = Number(qty) || 0;
119    });
120    write(lines);
121    return lines;
122  }
123
124  function clear() { write([]); }
125
126  /* The header badge is rendered server-side by webshop and therefore always says 0 for a
127   * guest. Paint the local count over it so the shopper can see their cart exists. */
128  function paintCount() {
129    var n = totalQty();
130    var nodes = document.querySelectorAll(".cart-badge, .shopping-badge, [data-cart-count]");
131    Array.prototype.forEach.call(nodes, function (el) {
132      el.textContent = n > 0 ? String(n) : "";
133      el.style.display = n > 0 ? "" : "none";
134    });
135  }
136
137  window.shipleyGuestCart = {
138    enabled: enabled,
139    isGuest: isGuest,
140    read: read,
141    add: add,
142    setQty: setQty,
143    clear: clear,
144    count: totalQty,
145    paintCount: paintCount
146  };
147
148  /* ---------------------------------------------------------------- cart page ------- */
149
150  function money(v, currency) {
151    var n = Number(v) || 0;
152    try {
153      return n.toLocaleString(undefined, { style: "currency", currency: currency || "USD" });
154    } catch (e) {
155      return "$" + n.toFixed(2);
156    }
157  }
158
159  function priceLabel(row) {
160    if (row.price_high && row.price_high !== row.price_low) {
161      return money(row.price_low, row.currency) + "–" + money(row.price_high, row.currency);
162    }
163    return money(row.price_low, row.currency);
164  }
165
166  function renderCart(data) {
167    var host = document.querySelector(".cart-container, #cart-container, main");
168    if (!host) return;
169
170    var wrap = document.createElement("div");
171    wrap.className = "sf-guest-cart";
172    var html = '<h2 class="sf-guest-cart-title">Your cart</h2><div class="sf-guest-cart-lines">';
173
174    data.lines.forEach(function (row) {
175      html +=
176        '<div class="sf-gc-line" data-gc-item="' + row.item_code + '">' +
177          '<div class="sf-gc-main">' +
178            '<a class="sf-gc-name" href="' + row.route + '">' + row.item_name + "</a>" +
179            '<div class="sf-gc-meta">' +
180              (row.piece
181                ? (row.mode === "weight" ? "By weight" : "By the piece")
182                : "") +
183              (row.subscribe_weeks
184                ? ' &middot; Delivers every ' + row.subscribe_weeks + " week" +
185                  (row.subscribe_weeks === 1 ? "" : "s")
186                : "") +
187            "</div>" +
188          "</div>" +
189          '<div class="sf-gc-qty">' +
190            '<button type="button" class="sf-gc-less" aria-label="Fewer">&minus;</button>' +
191            '<span class="sf-gc-n">' + row.qty + "</span>" +
192            '<button type="button" class="sf-gc-more" aria-label="More">+</button>' +
193          "</div>" +
194          '<div class="sf-gc-price">' + priceLabel(row) + "</div>" +
195          '<button type="button" class="sf-gc-remove" aria-label="Remove">Remove</button>' +
196        "</div>";
197    });
198
199    html += "</div>";
200
201    var totals = data.total_high && data.total_high !== data.total_low
202      ? money(data.total_low, data.currency) + "–" + money(data.total_high, data.currency)
203      : money(data.total_low, data.currency);
204
205    html +=
206      '<div class="sf-gc-foot">' +
207        '<div class="sf-gc-total"><span>Estimated total</span><strong>' + totals + "</strong></div>" +
208        '<p class="sf-gc-note">Cut by hand, so weights vary. Shipping and tax are ' +
209          "calculated at checkout.</p>" +
210        '<button type="button" class="sf-gc-checkout btn btn-primary btn-lg">Check out</button>' +
211        '<p class="sf-gc-signin">Already have an account? <a href="/login">Sign in</a></p>' +
212      "</div>";
213
214    wrap.innerHTML = html;
215
216    // Replace webshop's "Your cart is Empty" block, which is correct about the SERVER cart
217    // and wrong about what the shopper believes they have.
218    var empty = host.querySelector(".cart-empty, #cart-empty");
219    if (empty) {
220      empty.parentNode.replaceChild(wrap, empty);
221    } else {
222      host.insertBefore(wrap, host.firstChild);
223    }
224
225    wireCartEvents(wrap);
226  }
227
228  function wireCartEvents(wrap) {
229    wrap.addEventListener("click", function (ev) {
230      var line = ev.target.closest ? ev.target.closest(".sf-gc-line") : null;
231
232      if (ev.target.classList.contains("sf-gc-checkout")) {
233        ev.preventDefault();
234        startCheckout(ev.target);
235        return;
236      }
237      if (!line) return;
238      var code = line.getAttribute("data-gc-item");
239      var current = 0;
240      read().forEach(function (l) { if (l.item_code === code) current = Number(l.qty) || 0; });
241
242      if (ev.target.classList.contains("sf-gc-remove")) {
243        setQty(code, 0);
244      } else if (ev.target.classList.contains("sf-gc-more")) {
245        setQty(code, current + 1);
246      } else if (ev.target.classList.contains("sf-gc-less")) {
247        setQty(code, current - 1);
248      } else {
249        return;
250      }
251      paintCount();
252      loadAndRender();
253    });
254  }
255
256  function loadAndRender() {
257    var lines = read();
258    var existing = document.querySelector(".sf-guest-cart");
259    if (existing) existing.parentNode.removeChild(existing);
260    if (!lines.length) {
261      window.location.reload();
262      return;
263    }
264    if (!window.frappe || !frappe.call) return;
265
266    frappe.call({
267      type: "POST",
268      method: "shipley_checkout.guest_cart.preview_guest_cart",
269      args: { items: JSON.stringify(lines) },
270      callback: function (r) {
271        if (!r || r.exc || !r.message) return;
272        var data = r.message;
273        // Reconcile: anything the server dropped (unpublished since it was added) goes
274        // from the browser too, so checkout can never stall on a product nobody can buy.
275        if (data.lines.length !== lines.length) {
276          var keep = {};
277          data.lines.forEach(function (l) { keep[l.item_code] = true; });
278          write(read().filter(function (l) { return keep[l.item_code]; }));
279        }
280        if (!data.lines.length) { window.location.reload(); return; }
281        renderCart(data);
282        paintCount();
283      }
284    });
285  }
286
287  /* ---------------------------------------------------------------- checkout -------- */
288
289  function startCheckout(button) {
290    var lines = read();
291    if (!lines.length) return;
292    if (button) button.disabled = true;
293
294    // Reuse guest_express_checkout.withAccount rather than building a second email modal.
295    // It shows the modal, runs express_signup, handles existing_user / setup_incomplete
296    // itself, and calls back only once the session IS logged in. That path is already
297    // proven on this site; a parallel implementation would be a second thing to get wrong.
298    if (window.shipleyExpressCheckout && window.shipleyExpressCheckout.withAccount) {
299      window.shipleyExpressCheckout.withAccount(
300        { item_code: lines[0].item_code, qty: lines[0].qty },
301        function (overlay) {
302          if (overlay) {
303            var modal = overlay.querySelector(".sf-express-modal");
304            if (modal) modal.innerHTML = "<h3>Account created!</h3><p>Building your cart…</p>";
305          }
306          materialize(button);
307        }
308      );
309      return;
310    }
311
312    // withAccount unavailable (this file loaded, that one did not). Do not dead-end: send
313    // them to sign in. The local cart is kept, so nothing is lost.
314    window.location.href = "/login?redirect-to=%2Fcart";
315  }
316
317  function materialize(button) {
318    // No email argument: withAccount already created the account and logged this session
319    // in, so the server takes the party from the session. See materialize_guest_cart.
320    frappe.call({
321      type: "POST",
322      method: "shipley_checkout.guest_cart.materialize_guest_cart",
323      args: { items: JSON.stringify(read()) },
324      callback: function (r) {
325        if (button) button.disabled = false;
326        if (!r || r.exc || !r.message) return;
327        var res = r.message;
328        if (res.status === "created") {
329          // The cart now exists server-side. Drop the local copy FIRST so a reload cannot
330          // double-add, then hard-navigate: this session only just became logged in and
331          // every widget on the page still thinks it is anonymous.
332          clear();
333          window.location.href = "/cart";
334          return;
335        }
336        // existing_user / setup_incomplete. withAccount normally handles these inside the
337        // modal, so reaching here means the session was still Guest; keep the local cart
338        // and send them to sign in.
339        window.location.href = "/login?redirect-to=%2Fcart";
340      },
341      error: function () {
342        if (button) button.disabled = false;
343      }
344    });
345  }
346
347  /* ---------------------------------------------------------------- boot ------------ */
348
349  function boot() {
350    if (!isGuest()) {
351      // Signed in: a stale local cart would be confusing and is not needed, because the
352      // real cart took over at materialize.
353      if (read().length) clear();
354      return;
355    }
356    paintCount();
357    var path = (window.location.pathname || "").replace(/\/+$/, "");
358    if (path === "/cart" && read().length) {
359      loadAndRender();
360    }
361  }
362
363  if (document.readyState === "loading") {
364    document.addEventListener("DOMContentLoaded", boot);
365  } else {
366    boot();
367  }
368})();

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.