1/** 2 * kb-cart.js 3 * 4 * Owns the global cart store as `Alpine.store('kbCart')` so any 5 * element on the page (drawer, header button, ATC handlers, etc.) 6 * can bind to the same reactive state. 7 * 8 * Wire-up: 9 * - We register the store via `alpine:init` AND, if Alpine has 10 * already booted by the time this script runs (e.g. another 11 * plugin or a builder loads its own Alpine first), immediately 12 * on script load. Calling `Alpine.store('kbCart', â¦)` is safe 13 * either way â but we MUST register before any of Alpine's 14 * directive evaluations runs against the auto-injected cart 15 * drawer markup, otherwise the drawer's bindings throw "store 16 * undefined" errors that don't auto-recover. 17 * - Hydration (`refresh()`) is fire-and-forget on init so the count 18 * badge accurately reflects an existing cart cookie before the 19 * user interacts. 20 * 21 * Endpoint configuration is injected via wp_localize_script() as 22 * `window.kbShopify` (see Assets::register_assets()). 23 */ 24(function () { 25 'use strict'; 26 27 var registered = false; 28 29 function cfg() { 30 return window.kbShopify || {}; 31 } 32 33 function emptyCart() { 34 return { 35 id: '', 36 checkout_url: '', 37 total_quantity: 0, 38 currency_code: '', 39 cost: { subtotal: null, total: null, tax: null, duty: null }, 40 lines: [] 41 }; 42 } 43 44 async function request(path, options) { 45 options = options || {}; 46 var c = cfg(); 47 var url = (c.restUrl || '/wp-json/kb-shopify/v1/').replace(/\/$/, '/') + path.replace(/^\//, ''); 48 var headers = Object.assign( 49 { 'Content-Type': 'application/json', 'Accept': 'application/json' }, 50 options.headers || {} 51 ); 52 if (c.nonce) headers['X-WP-Nonce'] = c.nonce; 53 54 var response = await fetch(url, { 55 method: options.method || 'GET', 56 headers: headers, 57 credentials: 'same-origin', 58 body: options.body ? JSON.stringify(options.body) : undefined 59 }); 60 61 var json = null; 62 try { json = await response.json(); } catch (e) { /* tolerate empty body */ } 63 64 if (!response.ok) { 65 var err = new Error((json && json.message) || ('HTTP ' + response.status)); 66 err.status = response.status; 67 err.code = json && json.code; 68 err.data = json && json.data; 69 throw err; 70 } 71 72 return json; 73 } 74 75 function buildStore() { 76 return { 77 // ââ State ââââââââââââââââââââââââââââââââââââââââââââââ 78 id: '', 79 checkoutUrl: '', 80 totalQuantity: 0, 81 currencyCode: '', 82 cost: { subtotal: null, total: null, tax: null, duty: null }, 83 lines: [], 84 isOpen: false, 85 isBusy: false, 86 errorMessage: '', 87 hydrated: false, 88 89 // ââ Lifecycle ââââââââââââââââââââââââââââââââââââââââââ 90 init: function () { 91 var self = this; 92 93 window.addEventListener('kb-shopify:add-to-cart', function (e) { 94 var d = e.detail || {}; 95 if (!d.variantId) return; 96 self.add(d.variantId, d.quantity || 1, { open: true }); 97 }); 98 99 this.refresh(); 100 }, 101 102 // ââ Mutations ââââââââââââââââââââââââââââââââââââââââââ 103 refresh: async function () { 104 try { 105 var cart = await request('cart'); 106 this.applyCart(cart); 107 } catch (e) { 108 // Silent: failed hydration just leaves the cart empty. 109 console.warn('[kb-shopify] cart refresh failed', e); 110 } finally { 111 this.hydrated = true; 112 } 113 }, 114 115 add: async function (variantId, quantity, opts) { 116 if (!variantId) return; 117 opts = opts || {}; 118 this.beginRequest(); 119 try { 120 var cart = await request('cart/lines', { 121 method: 'POST', 122 body: { variant_id: variantId, quantity: Math.max(1, parseInt(quantity, 10) || 1) } 123 }); 124 this.applyCart(cart); 125 if (opts.open) this.open(); 126 } catch (e) { 127 this.errorMessage = e.message; 128 } finally { 129 this.endRequest(); 130 } 131 }, 132 133 updateQuantity: async function (lineId, quantity) { 134 if (!lineId) return; 135 quantity = Math.max(0, parseInt(quantity, 10) || 0); 136 this.beginRequest();
137 try { 138 var cart = await request('cart/lines/' + encodeURIComponent(lineId), { 139 method: 'PATCH', 140 body: { quantity: quantity } 141 }); 142 this.applyCart(cart); 143 } catch (e) { 144 this.errorMessage = e.message; 145 } finally { 146 this.endRequest(); 147 } 148 }, 149 150 increment: function (line) { 151 if (!line) return; 152 // Respect Shopify's tracked stock when present. Null means 153 // inventory isn't being tracked for this variant, so allow 154 // the increment and let the server be the authority. 155 var cap = this.lineMaxQty(line); 156 if (cap !== null && line.quantity >= cap) return; 157 this.updateQuantity(line.id, line.quantity + 1); 158 }, 159 160 lineMaxQty: function (line) { 161 var m = line && line.merchandise; 162 if (!m) return null; 163 if (typeof m.quantity_available !== 'number' || m.quantity_available <= 0) return null; 164 return m.quantity_available; 165 }, 166 167 decrement: function (line) { 168 if (!line) return; 169 // Quantity = 0 server-side maps to "remove line". 170 this.updateQuantity(line.id, line.quantity - 1); 171 }, 172 173 remove: async function (lineId) { 174 if (!lineId) return; 175 this.beginRequest(); 176 try { 177 var cart = await request('cart/lines/' + encodeURIComponent(lineId), { 178 method: 'DELETE' 179 }); 180 this.applyCart(cart); 181 } catch (e) { 182 this.errorMessage = e.message; 183 } finally { 184 this.endRequest(); 185 } 186 }, 187 188 // ââ UI ââââââââââââââââââââââââââââââââââââââââââââââââ 189 open: function () { this.errorMessage = ''; this.isOpen = true; document.documentElement.classList.add('kb-cart-locked'); }, 190 close: function () { this.isOpen = false; document.documentElement.classList.remove('kb-cart-locked'); }, 191 toggle: function () { this.isOpen ? this.close() : this.open(); }, 192 193 // ââ Helpers ââââââââââââââââââââââââââââââââââââââââââââ 194 applyCart: function (cart) { 195 if (!cart) return; 196 this.id = cart.id || ''; 197 this.checkoutUrl = cart.checkout_url || ''; 198 this.totalQuantity = cart.total_quantity || 0; 199 this.currencyCode = cart.currency_code || (cfg().currency || 'EUR'); 200 this.cost = cart.cost || emptyCart().cost; 201 this.lines = Array.isArray(cart.lines) ? cart.lines : []; 202 this.errorMessage = ''; 203 }, 204 205 beginRequest: function () { this.isBusy = true; this.errorMessage = ''; }, 206 endRequest: function () { this.isBusy = false; }, 207 208 formatMoney: function (money) { 209 if (!money || typeof money.amount !== 'number') return ''; 210 try { 211 return new Intl.NumberFormat( 212 (cfg().locale || 'en').replace('_', '-'), 213 { style: 'currency', currency: money.currency_code || this.currencyCode || 'EUR' } 214 ).format(money.amount); 215 } catch (e) { 216 return money.amount.toFixed(2) + ' ' + (money.currency_code || ''); 217 } 218 } 219 }; 220 } 221 222 function tryRegister() { 223 if (registered) return true; 224 if (!window.Alpine || typeof window.Alpine.store !== 'function') return false; 225 window.Alpine.store('kbCart', buildStore()); 226 registered = true; 227 return true; 228 } 229 230 // Cover both orderings: 231 // 1. We loaded before Alpine â alpine:init fires later. 232 // 2. Alpine already booted (e.g. a builder loaded its own copy 233 // first) â the listener never fires, so register now. 234 if (!tryRegister()) { 235 document.addEventListener('alpine:init', tryRegister); 236 document.addEventListener('alpine:initialized', tryRegister); 237 } 238})();
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.