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 ? ' · 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">−</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.