@magic-spells/cart-panel 0.3.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/cart-panel.cjs.css +216 -193
- package/dist/cart-panel.cjs.js +692 -508
- package/dist/cart-panel.cjs.js.map +1 -1
- package/dist/cart-panel.css +216 -193
- package/dist/cart-panel.esm.css +216 -193
- package/dist/cart-panel.esm.js +686 -500
- package/dist/cart-panel.esm.js.map +1 -1
- package/dist/cart-panel.js +1054 -1716
- package/dist/cart-panel.js.map +1 -1
- package/dist/cart-panel.min.css +1 -1
- package/dist/cart-panel.min.js +1 -1
- package/package.json +12 -8
- package/src/cart-item.js +448 -0
- package/src/cart-panel.css +231 -0
- package/src/cart-panel.js +258 -517
- package/dist/cart-panel.scss +0 -107
- package/src/cart-panel.scss +0 -107
package/dist/cart-panel.cjs.js
CHANGED
|
@@ -2,133 +2,495 @@
|
|
|
2
2
|
|
|
3
3
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
4
|
|
|
5
|
-
require('@magic-spells/focus-trap');
|
|
6
5
|
var EventEmitter = require('@magic-spells/event-emitter');
|
|
7
|
-
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// CartItem Component
|
|
9
|
+
// =============================================================================
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
|
-
*
|
|
11
|
-
* @extends HTMLElement
|
|
12
|
+
* CartItem class that handles the functionality of a cart item component
|
|
12
13
|
*/
|
|
13
|
-
class
|
|
14
|
-
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
14
|
+
class CartItem extends HTMLElement {
|
|
15
|
+
// Static template functions shared across all instances
|
|
16
|
+
static #templates = new Map();
|
|
17
|
+
static #processingTemplate = null;
|
|
18
|
+
|
|
19
|
+
// Private fields
|
|
20
|
+
#currentState = 'ready';
|
|
21
|
+
#isDestroying = false;
|
|
22
|
+
#isAppearing = false;
|
|
23
|
+
#handlers = {};
|
|
24
|
+
#itemData = null;
|
|
25
|
+
#cartData = null;
|
|
26
|
+
#lastRenderedHTML = '';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Set the template function for rendering cart items
|
|
30
|
+
* @param {string} name - Template name ('default' for default template)
|
|
31
|
+
* @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
|
|
32
|
+
*/
|
|
33
|
+
static setTemplate(name, templateFn) {
|
|
34
|
+
if (typeof name !== 'string') {
|
|
35
|
+
throw new Error('Template name must be a string');
|
|
36
|
+
}
|
|
37
|
+
if (typeof templateFn !== 'function') {
|
|
38
|
+
throw new Error('Template must be a function');
|
|
39
|
+
}
|
|
40
|
+
CartItem.#templates.set(name, templateFn);
|
|
41
|
+
}
|
|
18
42
|
|
|
19
43
|
/**
|
|
20
|
-
*
|
|
44
|
+
* Set the processing template function for rendering processing overlay
|
|
45
|
+
* @param {Function} templateFn - Function that returns HTML string for processing state
|
|
21
46
|
*/
|
|
47
|
+
static setProcessingTemplate(templateFn) {
|
|
48
|
+
if (typeof templateFn !== 'function') {
|
|
49
|
+
throw new Error('Processing template must be a function');
|
|
50
|
+
}
|
|
51
|
+
CartItem.#processingTemplate = templateFn;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a cart item with appearing animation
|
|
56
|
+
* @param {Object} itemData - Shopify cart item data
|
|
57
|
+
* @param {Object} cartData - Full Shopify cart object
|
|
58
|
+
* @returns {CartItem} Cart item instance that will animate in
|
|
59
|
+
*/
|
|
60
|
+
static createAnimated(itemData, cartData) {
|
|
61
|
+
return new CartItem(itemData, cartData, { animate: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Define which attributes should be observed for changes
|
|
66
|
+
*/
|
|
67
|
+
static get observedAttributes() {
|
|
68
|
+
return ['state', 'key'];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Called when observed attributes change
|
|
73
|
+
*/
|
|
74
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
75
|
+
if (oldValue === newValue) return;
|
|
76
|
+
|
|
77
|
+
if (name === 'state') {
|
|
78
|
+
this.#currentState = newValue || 'ready';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
constructor(itemData = null, cartData = null, options = {}) {
|
|
83
|
+
super();
|
|
84
|
+
|
|
85
|
+
// Store item and cart data if provided
|
|
86
|
+
this.#itemData = itemData;
|
|
87
|
+
this.#cartData = cartData;
|
|
88
|
+
|
|
89
|
+
// Set initial state - start with 'appearing' only if explicitly requested
|
|
90
|
+
const shouldAnimate = options.animate || this.hasAttribute('animate-in');
|
|
91
|
+
this.#currentState =
|
|
92
|
+
itemData && shouldAnimate ? 'appearing' : this.getAttribute('state') || 'ready';
|
|
93
|
+
|
|
94
|
+
// Bind event handlers
|
|
95
|
+
this.#handlers = {
|
|
96
|
+
click: this.#handleClick.bind(this),
|
|
97
|
+
change: this.#handleChange.bind(this),
|
|
98
|
+
transitionEnd: this.#handleTransitionEnd.bind(this),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
connectedCallback() {
|
|
103
|
+
const _ = this;
|
|
104
|
+
|
|
105
|
+
// If we have item data, render it first
|
|
106
|
+
if (_.#itemData) _.#render();
|
|
107
|
+
|
|
108
|
+
// Find child elements and attach listeners
|
|
109
|
+
_.#queryDOM();
|
|
110
|
+
_.#updateLinePriceElements();
|
|
111
|
+
_.#attachListeners();
|
|
112
|
+
|
|
113
|
+
// If we started with 'appearing' state, handle the entry animation
|
|
114
|
+
if (_.#currentState === 'appearing') {
|
|
115
|
+
_.setAttribute('state', 'appearing');
|
|
116
|
+
_.#isAppearing = true;
|
|
117
|
+
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
_.style.height = `${_.scrollHeight}px`;
|
|
120
|
+
requestAnimationFrame(() => _.setState('ready'));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
22
125
|
disconnectedCallback() {
|
|
126
|
+
// Cleanup event listeners
|
|
127
|
+
this.#detachListeners();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Query and cache DOM elements
|
|
132
|
+
*/
|
|
133
|
+
#queryDOM() {
|
|
134
|
+
this.content = this.querySelector('cart-item-content');
|
|
135
|
+
this.processing = this.querySelector('cart-item-processing');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Attach event listeners
|
|
140
|
+
*/
|
|
141
|
+
#attachListeners() {
|
|
142
|
+
const _ = this;
|
|
143
|
+
_.addEventListener('click', _.#handlers.click);
|
|
144
|
+
_.addEventListener('change', _.#handlers.change);
|
|
145
|
+
_.addEventListener('quantity-input:change', _.#handlers.change);
|
|
146
|
+
_.addEventListener('transitionend', _.#handlers.transitionEnd);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Detach event listeners
|
|
151
|
+
*/
|
|
152
|
+
#detachListeners() {
|
|
23
153
|
const _ = this;
|
|
24
|
-
|
|
25
|
-
|
|
154
|
+
_.removeEventListener('click', _.#handlers.click);
|
|
155
|
+
_.removeEventListener('change', _.#handlers.change);
|
|
156
|
+
_.removeEventListener('quantity-input:change', _.#handlers.change);
|
|
157
|
+
_.removeEventListener('transitionend', _.#handlers.transitionEnd);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get the current state
|
|
162
|
+
*/
|
|
163
|
+
get state() {
|
|
164
|
+
return this.#currentState;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get the cart key for this item
|
|
169
|
+
*/
|
|
170
|
+
get cartKey() {
|
|
171
|
+
return this.getAttribute('key');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Handle click events (for Remove buttons, etc.)
|
|
176
|
+
*/
|
|
177
|
+
#handleClick(e) {
|
|
178
|
+
// Check if clicked element is a remove button
|
|
179
|
+
const removeButton = e.target.closest('[data-action-remove-item]');
|
|
180
|
+
if (removeButton) {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
this.#emitRemoveEvent();
|
|
26
183
|
}
|
|
184
|
+
}
|
|
27
185
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Handle change events (for quantity inputs and quantity-input component)
|
|
188
|
+
*/
|
|
189
|
+
#handleChange(e) {
|
|
190
|
+
// Check if event is from quantity-input component
|
|
191
|
+
if (e.type === 'quantity-input:change') {
|
|
192
|
+
this.#emitQuantityChangeEvent(e.detail.value);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
31
195
|
|
|
32
|
-
//
|
|
33
|
-
|
|
196
|
+
// Check if changed element is a quantity input
|
|
197
|
+
const quantityInput = e.target.closest('[data-cart-quantity]');
|
|
198
|
+
if (quantityInput) {
|
|
199
|
+
this.#emitQuantityChangeEvent(quantityInput.value);
|
|
200
|
+
}
|
|
34
201
|
}
|
|
35
202
|
|
|
36
203
|
/**
|
|
37
|
-
*
|
|
38
|
-
* @private
|
|
204
|
+
* Handle transition end events for destroy animation and appearing animation
|
|
39
205
|
*/
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
206
|
+
#handleTransitionEnd(e) {
|
|
207
|
+
if (e.propertyName === 'height' && this.#isDestroying) {
|
|
208
|
+
// Remove from DOM after height animation completes
|
|
209
|
+
this.remove();
|
|
210
|
+
} else if (e.propertyName === 'height' && this.#isAppearing) {
|
|
211
|
+
// Remove explicit height after appearing animation completes
|
|
212
|
+
this.style.height = '';
|
|
213
|
+
this.#isAppearing = false;
|
|
214
|
+
}
|
|
43
215
|
}
|
|
44
216
|
|
|
45
217
|
/**
|
|
46
|
-
*
|
|
47
|
-
* @private
|
|
218
|
+
* Emit remove event
|
|
48
219
|
*/
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
220
|
+
#emitRemoveEvent() {
|
|
221
|
+
this.dispatchEvent(
|
|
222
|
+
new CustomEvent('cart-item:remove', {
|
|
223
|
+
bubbles: true,
|
|
224
|
+
detail: {
|
|
225
|
+
cartKey: this.cartKey,
|
|
226
|
+
element: this,
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
);
|
|
52
230
|
}
|
|
53
231
|
|
|
54
232
|
/**
|
|
55
|
-
*
|
|
233
|
+
* Emit quantity change event
|
|
56
234
|
*/
|
|
57
|
-
|
|
58
|
-
|
|
235
|
+
#emitQuantityChangeEvent(quantity) {
|
|
236
|
+
this.dispatchEvent(
|
|
237
|
+
new CustomEvent('cart-item:quantity-change', {
|
|
238
|
+
bubbles: true,
|
|
239
|
+
detail: {
|
|
240
|
+
cartKey: this.cartKey,
|
|
241
|
+
quantity: parseInt(quantity),
|
|
242
|
+
element: this,
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Render cart item from data using the appropriate template
|
|
250
|
+
*/
|
|
251
|
+
#render() {
|
|
59
252
|
const _ = this;
|
|
60
|
-
_.
|
|
61
|
-
_.setAttribute('role', 'dialog');
|
|
62
|
-
_.setAttribute('aria-modal', 'true');
|
|
63
|
-
_.setAttribute('aria-hidden', 'true');
|
|
253
|
+
if (!_.#itemData || CartItem.#templates.size === 0) return;
|
|
64
254
|
|
|
65
|
-
|
|
255
|
+
// Set the key attribute from item data
|
|
256
|
+
const key = _.#itemData.key || _.#itemData.id;
|
|
257
|
+
if (key) _.setAttribute('key', key);
|
|
66
258
|
|
|
67
|
-
//
|
|
68
|
-
|
|
259
|
+
// Generate HTML from template and store for future comparisons
|
|
260
|
+
const templateHTML = _.#generateTemplateHTML();
|
|
261
|
+
_.#lastRenderedHTML = templateHTML;
|
|
69
262
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
263
|
+
// Generate processing HTML from template or use default
|
|
264
|
+
const processingHTML = CartItem.#processingTemplate
|
|
265
|
+
? CartItem.#processingTemplate()
|
|
266
|
+
: '<div class="cart-item-loader"></div>';
|
|
74
267
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
268
|
+
// Create the cart-item structure with template content inside cart-item-content
|
|
269
|
+
_.innerHTML = `
|
|
270
|
+
<cart-item-content>
|
|
271
|
+
${templateHTML}
|
|
272
|
+
</cart-item-content>
|
|
273
|
+
<cart-item-processing>
|
|
274
|
+
${processingHTML}
|
|
275
|
+
</cart-item-processing>
|
|
276
|
+
`;
|
|
79
277
|
}
|
|
80
278
|
|
|
81
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Update the cart item with new data
|
|
281
|
+
* @param {Object} itemData - Shopify cart item data
|
|
282
|
+
* @param {Object} cartData - Full Shopify cart object
|
|
283
|
+
*/
|
|
284
|
+
setData(itemData, cartData = null) {
|
|
82
285
|
const _ = this;
|
|
83
286
|
|
|
84
|
-
//
|
|
85
|
-
_
|
|
287
|
+
// Update internal data
|
|
288
|
+
_.#itemData = itemData;
|
|
289
|
+
if (cartData) _.#cartData = cartData;
|
|
290
|
+
|
|
291
|
+
// Generate new HTML with updated data
|
|
292
|
+
const newHTML = _.#generateTemplateHTML();
|
|
86
293
|
|
|
87
|
-
|
|
88
|
-
|
|
294
|
+
// Compare with previously rendered HTML
|
|
295
|
+
if (newHTML === _.#lastRenderedHTML) {
|
|
296
|
+
// HTML hasn't changed, just reset processing state
|
|
297
|
+
_.setState('ready');
|
|
298
|
+
_.#updateQuantityInput();
|
|
89
299
|
return;
|
|
90
300
|
}
|
|
91
301
|
|
|
92
|
-
//
|
|
93
|
-
_.
|
|
94
|
-
|
|
95
|
-
|
|
302
|
+
// HTML is different, proceed with full update
|
|
303
|
+
_.setState('ready');
|
|
304
|
+
_.#render();
|
|
305
|
+
_.#queryDOM();
|
|
306
|
+
_.#updateLinePriceElements();
|
|
307
|
+
}
|
|
96
308
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Generate HTML from the current template with current data
|
|
311
|
+
* @returns {string} Generated HTML string or empty string if no template
|
|
312
|
+
* @private
|
|
313
|
+
*/
|
|
314
|
+
#generateTemplateHTML() {
|
|
315
|
+
// If no templates are available, return empty string
|
|
316
|
+
if (!this.#itemData || CartItem.#templates.size === 0) {
|
|
317
|
+
return '';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Determine which template to use
|
|
321
|
+
const templateName = this.#itemData.properties?._cart_template || 'default';
|
|
322
|
+
const templateFn = CartItem.#templates.get(templateName) || CartItem.#templates.get('default');
|
|
100
323
|
|
|
101
|
-
|
|
102
|
-
|
|
324
|
+
if (!templateFn) {
|
|
325
|
+
return '';
|
|
103
326
|
}
|
|
104
327
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
328
|
+
// Generate and return HTML from template
|
|
329
|
+
return templateFn(this.#itemData, this.#cartData);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Update quantity input component to match server data
|
|
334
|
+
* @private
|
|
335
|
+
*/
|
|
336
|
+
#updateQuantityInput() {
|
|
337
|
+
if (!this.#itemData) return;
|
|
338
|
+
|
|
339
|
+
const quantityInput = this.querySelector('quantity-input');
|
|
340
|
+
if (quantityInput) {
|
|
341
|
+
quantityInput.value = this.#itemData.quantity;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Update elements with data-content-line-price attribute
|
|
347
|
+
* @private
|
|
348
|
+
*/
|
|
349
|
+
#updateLinePriceElements() {
|
|
350
|
+
if (!this.#itemData) return;
|
|
351
|
+
|
|
352
|
+
const linePriceElements = this.querySelectorAll('[data-content-line-price]');
|
|
353
|
+
const formattedLinePrice = this.#formatCurrency(this.#itemData.line_price || 0);
|
|
354
|
+
|
|
355
|
+
linePriceElements.forEach((element) => {
|
|
356
|
+
element.textContent = formattedLinePrice;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Format currency value from cents to dollar string
|
|
362
|
+
* @param {number} cents - Price in cents
|
|
363
|
+
* @returns {string} Formatted currency string (e.g., "$29.99")
|
|
364
|
+
* @private
|
|
365
|
+
*/
|
|
366
|
+
#formatCurrency(cents) {
|
|
367
|
+
if (typeof cents !== 'number') return '$0.00';
|
|
368
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Get the current item data
|
|
373
|
+
*/
|
|
374
|
+
get itemData() {
|
|
375
|
+
return this.#itemData;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Set the state of the cart item
|
|
380
|
+
* @param {string} state - 'ready', 'processing', 'destroying', or 'appearing'
|
|
381
|
+
*/
|
|
382
|
+
setState(state) {
|
|
383
|
+
if (['ready', 'processing', 'destroying', 'appearing'].includes(state)) {
|
|
384
|
+
this.setAttribute('state', state);
|
|
114
385
|
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Gracefully animate this cart item closed, then remove it
|
|
390
|
+
*/
|
|
391
|
+
destroyYourself() {
|
|
392
|
+
const _ = this;
|
|
393
|
+
|
|
394
|
+
// bail if already in the middle of a destroy cycle
|
|
395
|
+
if (_.#isDestroying) return;
|
|
396
|
+
_.#isDestroying = true;
|
|
397
|
+
|
|
398
|
+
// snapshot the current rendered height before applying any "destroying" styles
|
|
399
|
+
const initialHeight = _.offsetHeight;
|
|
400
|
+
_.setState('destroying');
|
|
401
|
+
|
|
402
|
+
// lock the measured height on the next animation frame to ensure layout is fully flushed
|
|
403
|
+
requestAnimationFrame(() => {
|
|
404
|
+
_.style.height = `${initialHeight}px`;
|
|
405
|
+
|
|
406
|
+
// read the css custom property for timing, defaulting to 400ms
|
|
407
|
+
const destroyDuration =
|
|
408
|
+
getComputedStyle(_).getPropertyValue('--cart-item-destroying-duration')?.trim() || '400ms';
|
|
409
|
+
|
|
410
|
+
// animate only the height to zero; other properties stay under stylesheet control
|
|
411
|
+
_.style.transition = `height ${destroyDuration} ease`;
|
|
412
|
+
_.style.height = '0px';
|
|
413
|
+
|
|
414
|
+
setTimeout(() => _.remove(), 600);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Supporting component classes for cart item
|
|
421
|
+
*/
|
|
422
|
+
class CartItemContent extends HTMLElement {
|
|
423
|
+
constructor() {
|
|
424
|
+
super();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
class CartItemProcessing extends HTMLElement {
|
|
429
|
+
constructor() {
|
|
430
|
+
super();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// =============================================================================
|
|
435
|
+
// Register Custom Elements
|
|
436
|
+
// =============================================================================
|
|
437
|
+
|
|
438
|
+
if (!customElements.get('cart-item')) {
|
|
439
|
+
customElements.define('cart-item', CartItem);
|
|
440
|
+
}
|
|
441
|
+
if (!customElements.get('cart-item-content')) {
|
|
442
|
+
customElements.define('cart-item-content', CartItemContent);
|
|
443
|
+
}
|
|
444
|
+
if (!customElements.get('cart-item-processing')) {
|
|
445
|
+
customElements.define('cart-item-processing', CartItemProcessing);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Make CartItem available globally for Shopify themes
|
|
449
|
+
if (typeof window !== 'undefined') {
|
|
450
|
+
window.CartItem = CartItem;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// =============================================================================
|
|
454
|
+
// CartPanel Component
|
|
455
|
+
// =============================================================================
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Shopping cart panel web component for Shopify.
|
|
459
|
+
* Manages cart data and AJAX requests, delegates modal behavior to dialog-panel.
|
|
460
|
+
* @extends HTMLElement
|
|
461
|
+
*/
|
|
462
|
+
class CartPanel extends HTMLElement {
|
|
463
|
+
#currentCart = null;
|
|
464
|
+
#eventEmitter;
|
|
465
|
+
#isInitialRender = true;
|
|
466
|
+
|
|
467
|
+
constructor() {
|
|
468
|
+
super();
|
|
469
|
+
this.#eventEmitter = new EventEmitter();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
connectedCallback() {
|
|
473
|
+
this.#attachListeners();
|
|
115
474
|
|
|
116
|
-
//
|
|
117
|
-
if (!
|
|
118
|
-
|
|
475
|
+
// Load cart data immediately unless manual mode is enabled
|
|
476
|
+
if (!this.hasAttribute('manual')) {
|
|
477
|
+
this.refreshCart();
|
|
119
478
|
}
|
|
120
|
-
|
|
121
|
-
_.#bindKeyboard();
|
|
479
|
+
}
|
|
122
480
|
|
|
123
|
-
|
|
124
|
-
|
|
481
|
+
disconnectedCallback() {
|
|
482
|
+
// Clean up handled by garbage collection
|
|
125
483
|
}
|
|
126
484
|
|
|
485
|
+
// =========================================================================
|
|
486
|
+
// Public API - Event Emitter
|
|
487
|
+
// =========================================================================
|
|
488
|
+
|
|
127
489
|
/**
|
|
128
|
-
*
|
|
129
|
-
* @param {string} eventName - Name of the event
|
|
130
|
-
* @param {Function} callback - Callback function
|
|
131
|
-
* @returns {
|
|
490
|
+
* Add an event listener
|
|
491
|
+
* @param {string} eventName - Name of the event
|
|
492
|
+
* @param {Function} callback - Callback function
|
|
493
|
+
* @returns {CartPanel} Returns this for method chaining
|
|
132
494
|
*/
|
|
133
495
|
on(eventName, callback) {
|
|
134
496
|
this.#eventEmitter.on(eventName, callback);
|
|
@@ -136,26 +498,164 @@ class CartDialog extends HTMLElement {
|
|
|
136
498
|
}
|
|
137
499
|
|
|
138
500
|
/**
|
|
139
|
-
*
|
|
140
|
-
* @param {string} eventName - Name of the event
|
|
141
|
-
* @param {Function} callback - Callback function
|
|
142
|
-
* @returns {
|
|
501
|
+
* Remove an event listener
|
|
502
|
+
* @param {string} eventName - Name of the event
|
|
503
|
+
* @param {Function} callback - Callback function
|
|
504
|
+
* @returns {CartPanel} Returns this for method chaining
|
|
143
505
|
*/
|
|
144
506
|
off(eventName, callback) {
|
|
145
507
|
this.#eventEmitter.off(eventName, callback);
|
|
146
508
|
return this;
|
|
147
509
|
}
|
|
148
510
|
|
|
511
|
+
// =========================================================================
|
|
512
|
+
// Public API - Dialog Control
|
|
513
|
+
// =========================================================================
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Show the cart by finding and opening the nearest dialog-panel ancestor
|
|
517
|
+
* @param {HTMLElement} [triggerEl=null] - The element that triggered the open
|
|
518
|
+
* @param {Object} [cartObj=null] - Optional cart object to use instead of fetching
|
|
519
|
+
*/
|
|
520
|
+
show(triggerEl = null, cartObj = null) {
|
|
521
|
+
const _ = this;
|
|
522
|
+
const dialogPanel = _.#findDialogPanel();
|
|
523
|
+
|
|
524
|
+
if (dialogPanel) {
|
|
525
|
+
dialogPanel.show(triggerEl);
|
|
526
|
+
_.refreshCart(cartObj);
|
|
527
|
+
_.#emit('cart-panel:show', { triggerElement: triggerEl });
|
|
528
|
+
} else {
|
|
529
|
+
console.warn('cart-panel: No dialog-panel ancestor found. Cart panel is visible but not in a modal.');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Hide the cart by finding and closing the nearest dialog-panel ancestor
|
|
535
|
+
*/
|
|
536
|
+
hide() {
|
|
537
|
+
const dialogPanel = this.#findDialogPanel();
|
|
538
|
+
if (dialogPanel) {
|
|
539
|
+
dialogPanel.hide();
|
|
540
|
+
this.#emit('cart-panel:hide', {});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// =========================================================================
|
|
545
|
+
// Public API - Cart Data
|
|
546
|
+
// =========================================================================
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Fetch current cart data from Shopify
|
|
550
|
+
* @returns {Promise<Object>} Cart data object
|
|
551
|
+
*/
|
|
552
|
+
getCart() {
|
|
553
|
+
return fetch('/cart.json', {
|
|
554
|
+
credentials: 'same-origin',
|
|
555
|
+
})
|
|
556
|
+
.then((response) => {
|
|
557
|
+
if (!response.ok) {
|
|
558
|
+
throw Error(response.statusText);
|
|
559
|
+
}
|
|
560
|
+
return response.json();
|
|
561
|
+
})
|
|
562
|
+
.catch((error) => {
|
|
563
|
+
console.error('Error fetching cart:', error);
|
|
564
|
+
return { error: true, message: error.message };
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
149
568
|
/**
|
|
150
|
-
*
|
|
151
|
-
* @param {string}
|
|
152
|
-
* @param {
|
|
569
|
+
* Update cart item quantity on Shopify
|
|
570
|
+
* @param {string|number} key - Cart item key/ID
|
|
571
|
+
* @param {number} quantity - New quantity (0 to remove)
|
|
572
|
+
* @returns {Promise<Object>} Updated cart data object
|
|
573
|
+
*/
|
|
574
|
+
updateCartItem(key, quantity) {
|
|
575
|
+
return fetch('/cart/change.json', {
|
|
576
|
+
method: 'POST',
|
|
577
|
+
credentials: 'same-origin',
|
|
578
|
+
body: JSON.stringify({ id: key, quantity: quantity }),
|
|
579
|
+
headers: { 'Content-Type': 'application/json' },
|
|
580
|
+
})
|
|
581
|
+
.then((response) => {
|
|
582
|
+
if (!response.ok) {
|
|
583
|
+
throw Error(response.statusText);
|
|
584
|
+
}
|
|
585
|
+
return response.json();
|
|
586
|
+
})
|
|
587
|
+
.catch((error) => {
|
|
588
|
+
console.error('Error updating cart item:', error);
|
|
589
|
+
return { error: true, message: error.message };
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Refresh cart display - fetches from server if no cart object provided
|
|
595
|
+
* @param {Object} [cartObj=null] - Cart data object to render, or null to fetch
|
|
596
|
+
* @returns {Promise<Object>} Cart data object
|
|
597
|
+
*/
|
|
598
|
+
async refreshCart(cartObj = null) {
|
|
599
|
+
const _ = this;
|
|
600
|
+
|
|
601
|
+
// Fetch from server if no cart object provided
|
|
602
|
+
cartObj = cartObj || (await _.getCart());
|
|
603
|
+
if (!cartObj || cartObj.error) {
|
|
604
|
+
console.warn('Cart data has error or is null:', cartObj);
|
|
605
|
+
return cartObj;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
_.#currentCart = cartObj;
|
|
609
|
+
_.#renderCartItems(cartObj);
|
|
610
|
+
_.#renderCartPanel(cartObj);
|
|
611
|
+
|
|
612
|
+
const cartWithCalculatedFields = _.#addCalculatedFields(cartObj);
|
|
613
|
+
_.#emit('cart-panel:refreshed', { cart: cartWithCalculatedFields });
|
|
614
|
+
_.#emit('cart-panel:data-changed', cartWithCalculatedFields);
|
|
615
|
+
|
|
616
|
+
return cartObj;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// =========================================================================
|
|
620
|
+
// Public API - Templates
|
|
621
|
+
// =========================================================================
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Set the template function for cart items
|
|
625
|
+
* @param {string} templateName - Name of the template
|
|
626
|
+
* @param {Function} templateFn - Function that takes (itemData, cartData) and returns HTML string
|
|
627
|
+
*/
|
|
628
|
+
setCartItemTemplate(templateName, templateFn) {
|
|
629
|
+
CartItem.setTemplate(templateName, templateFn);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Set the processing template function for cart items
|
|
634
|
+
* @param {Function} templateFn - Function that returns HTML string for processing state
|
|
635
|
+
*/
|
|
636
|
+
setCartItemProcessingTemplate(templateFn) {
|
|
637
|
+
CartItem.setProcessingTemplate(templateFn);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// =========================================================================
|
|
641
|
+
// Private Methods - Core
|
|
642
|
+
// =========================================================================
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Find the nearest dialog-panel ancestor
|
|
646
|
+
* @private
|
|
647
|
+
*/
|
|
648
|
+
#findDialogPanel() {
|
|
649
|
+
return this.closest('dialog-panel');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Emit an event via EventEmitter and native CustomEvent
|
|
153
654
|
* @private
|
|
154
655
|
*/
|
|
155
656
|
#emit(eventName, data = null) {
|
|
156
657
|
this.#eventEmitter.emit(eventName, data);
|
|
157
658
|
|
|
158
|
-
// Also emit as native DOM events for better compatibility
|
|
159
659
|
this.dispatchEvent(
|
|
160
660
|
new CustomEvent(eventName, {
|
|
161
661
|
detail: data,
|
|
@@ -165,98 +665,57 @@ class CartDialog extends HTMLElement {
|
|
|
165
665
|
}
|
|
166
666
|
|
|
167
667
|
/**
|
|
168
|
-
* Attach event listeners
|
|
668
|
+
* Attach event listeners
|
|
169
669
|
* @private
|
|
170
670
|
*/
|
|
171
671
|
#attachListeners() {
|
|
172
|
-
const _ = this;
|
|
173
|
-
|
|
174
|
-
// Handle trigger buttons
|
|
175
|
-
document.addEventListener('click', (e) => {
|
|
176
|
-
const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
|
|
177
|
-
if (!trigger) return;
|
|
178
|
-
|
|
179
|
-
if (trigger.getAttribute('data-prevent-default') === 'true') {
|
|
180
|
-
e.preventDefault();
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
_.show(trigger);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
672
|
// Handle close buttons
|
|
187
|
-
|
|
673
|
+
this.addEventListener('click', (e) => {
|
|
188
674
|
if (!e.target.closest('[data-action-hide-cart]')) return;
|
|
189
|
-
|
|
675
|
+
this.hide();
|
|
190
676
|
});
|
|
191
677
|
|
|
192
678
|
// Handle cart item remove events
|
|
193
|
-
|
|
194
|
-
|
|
679
|
+
this.addEventListener('cart-item:remove', (e) => {
|
|
680
|
+
this.#handleCartItemRemove(e);
|
|
195
681
|
});
|
|
196
682
|
|
|
197
683
|
// Handle cart item quantity change events
|
|
198
|
-
|
|
199
|
-
|
|
684
|
+
this.addEventListener('cart-item:quantity-change', (e) => {
|
|
685
|
+
this.#handleCartItemQuantityChange(e);
|
|
200
686
|
});
|
|
201
|
-
|
|
202
|
-
// Add transition end listener
|
|
203
|
-
_.contentPanel.addEventListener('transitionend', _.#handleTransitionEnd);
|
|
204
687
|
}
|
|
205
688
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
*/
|
|
210
|
-
#detachListeners() {
|
|
211
|
-
const _ = this;
|
|
212
|
-
if (_.contentPanel) {
|
|
213
|
-
_.contentPanel.removeEventListener('transitionend', _.#handleTransitionEnd);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Binds keyboard events for accessibility
|
|
219
|
-
* @private
|
|
220
|
-
*/
|
|
221
|
-
#bindKeyboard() {
|
|
222
|
-
this.addEventListener('keydown', (e) => {
|
|
223
|
-
if (e.key === 'Escape') {
|
|
224
|
-
this.hide();
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
}
|
|
689
|
+
// =========================================================================
|
|
690
|
+
// Private Methods - Cart Item Event Handlers
|
|
691
|
+
// =========================================================================
|
|
228
692
|
|
|
229
693
|
/**
|
|
230
694
|
* Handle cart item removal
|
|
231
695
|
* @private
|
|
232
696
|
*/
|
|
233
697
|
#handleCartItemRemove(e) {
|
|
698
|
+
const _ = this;
|
|
234
699
|
const { cartKey, element } = e.detail;
|
|
235
700
|
|
|
236
|
-
// Set item to processing state
|
|
237
701
|
element.setState('processing');
|
|
238
702
|
|
|
239
|
-
|
|
240
|
-
this.updateCartItem(cartKey, 0)
|
|
703
|
+
_.updateCartItem(cartKey, 0)
|
|
241
704
|
.then((updatedCart) => {
|
|
242
705
|
if (updatedCart && !updatedCart.error) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
|
|
251
|
-
this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
|
|
706
|
+
_.#currentCart = updatedCart;
|
|
707
|
+
_.#renderCartItems(updatedCart);
|
|
708
|
+
_.#renderCartPanel(updatedCart);
|
|
709
|
+
|
|
710
|
+
const cartWithCalculatedFields = _.#addCalculatedFields(updatedCart);
|
|
711
|
+
_.#emit('cart-panel:updated', { cart: cartWithCalculatedFields });
|
|
712
|
+
_.#emit('cart-panel:data-changed', cartWithCalculatedFields);
|
|
252
713
|
} else {
|
|
253
|
-
// Error - reset to ready state
|
|
254
714
|
element.setState('ready');
|
|
255
715
|
console.error('Failed to remove cart item:', cartKey);
|
|
256
716
|
}
|
|
257
717
|
})
|
|
258
718
|
.catch((error) => {
|
|
259
|
-
// Error - reset to ready state
|
|
260
719
|
element.setState('ready');
|
|
261
720
|
console.error('Error removing cart item:', error);
|
|
262
721
|
});
|
|
@@ -267,49 +726,46 @@ class CartDialog extends HTMLElement {
|
|
|
267
726
|
* @private
|
|
268
727
|
*/
|
|
269
728
|
#handleCartItemQuantityChange(e) {
|
|
729
|
+
const _ = this;
|
|
270
730
|
const { cartKey, quantity, element } = e.detail;
|
|
271
731
|
|
|
272
|
-
// Set item to processing state
|
|
273
732
|
element.setState('processing');
|
|
274
733
|
|
|
275
|
-
|
|
276
|
-
this.updateCartItem(cartKey, quantity)
|
|
734
|
+
_.updateCartItem(cartKey, quantity)
|
|
277
735
|
.then((updatedCart) => {
|
|
278
736
|
if (updatedCart && !updatedCart.error) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
this.#emit('cart-dialog:updated', { cart: cartWithCalculatedFields });
|
|
287
|
-
this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
|
|
737
|
+
_.#currentCart = updatedCart;
|
|
738
|
+
_.#renderCartItems(updatedCart);
|
|
739
|
+
_.#renderCartPanel(updatedCart);
|
|
740
|
+
|
|
741
|
+
const cartWithCalculatedFields = _.#addCalculatedFields(updatedCart);
|
|
742
|
+
_.#emit('cart-panel:updated', { cart: cartWithCalculatedFields });
|
|
743
|
+
_.#emit('cart-panel:data-changed', cartWithCalculatedFields);
|
|
288
744
|
} else {
|
|
289
|
-
// Error - reset to ready state
|
|
290
745
|
element.setState('ready');
|
|
291
746
|
console.error('Failed to update cart item quantity:', cartKey, quantity);
|
|
292
747
|
}
|
|
293
748
|
})
|
|
294
749
|
.catch((error) => {
|
|
295
|
-
// Error - reset to ready state
|
|
296
750
|
element.setState('ready');
|
|
297
751
|
console.error('Error updating cart item quantity:', error);
|
|
298
752
|
});
|
|
299
753
|
}
|
|
300
754
|
|
|
755
|
+
// =========================================================================
|
|
756
|
+
// Private Methods - Rendering
|
|
757
|
+
// =========================================================================
|
|
758
|
+
|
|
301
759
|
/**
|
|
302
|
-
* Update cart count elements across the
|
|
760
|
+
* Update cart count elements across the page
|
|
303
761
|
* @private
|
|
304
762
|
*/
|
|
305
763
|
#renderCartCount(cartData) {
|
|
306
764
|
if (!cartData) return;
|
|
307
765
|
|
|
308
|
-
// Calculate visible item count (excluding _hide_in_cart items)
|
|
309
766
|
const visibleItems = this.#getVisibleCartItems(cartData);
|
|
310
767
|
const visibleItemCount = visibleItems.reduce((total, item) => total + item.quantity, 0);
|
|
311
768
|
|
|
312
|
-
// Update all cart count elements across the site
|
|
313
769
|
const cartCountElements = document.querySelectorAll('[data-content-cart-count]');
|
|
314
770
|
cartCountElements.forEach((element) => {
|
|
315
771
|
element.textContent = visibleItemCount;
|
|
@@ -317,170 +773,110 @@ class CartDialog extends HTMLElement {
|
|
|
317
773
|
}
|
|
318
774
|
|
|
319
775
|
/**
|
|
320
|
-
* Update cart subtotal elements across the
|
|
776
|
+
* Update cart subtotal elements across the page
|
|
321
777
|
* @private
|
|
322
778
|
*/
|
|
323
779
|
#renderCartSubtotal(cartData) {
|
|
324
780
|
if (!cartData) return;
|
|
325
781
|
|
|
326
|
-
// Calculate subtotal from all items except those marked to ignore pricing
|
|
327
782
|
const pricedItems = cartData.items.filter((item) => {
|
|
328
783
|
const ignorePrice = item.properties?._ignore_price_in_subtotal;
|
|
329
784
|
return !ignorePrice;
|
|
330
785
|
});
|
|
331
786
|
const subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
|
|
332
787
|
|
|
333
|
-
// Update all cart subtotal elements across the site
|
|
334
788
|
const cartSubtotalElements = document.querySelectorAll('[data-content-cart-subtotal]');
|
|
335
789
|
cartSubtotalElements.forEach((element) => {
|
|
336
|
-
// Format as currency (assuming cents, convert to dollars)
|
|
337
790
|
const formatted = (subtotal / 100).toFixed(2);
|
|
338
791
|
element.textContent = `$${formatted}`;
|
|
339
792
|
});
|
|
340
793
|
}
|
|
341
794
|
|
|
342
795
|
/**
|
|
343
|
-
* Update cart
|
|
796
|
+
* Update cart panel sections (has-items/empty)
|
|
344
797
|
* @private
|
|
345
798
|
*/
|
|
346
799
|
#renderCartPanel(cart = null) {
|
|
347
|
-
const
|
|
800
|
+
const _ = this;
|
|
801
|
+
const cartData = cart || _.#currentCart;
|
|
348
802
|
if (!cartData) return;
|
|
349
803
|
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
const emptySection = this.querySelector('[data-cart-is-empty]');
|
|
353
|
-
const itemsContainer = this.querySelector('[data-content-cart-items]');
|
|
804
|
+
const visibleItems = _.#getVisibleCartItems(cartData);
|
|
805
|
+
const hasVisibleItems = visibleItems.length > 0;
|
|
354
806
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
'Cart sections not found. Expected [data-cart-has-items], [data-cart-is-empty], and [data-content-cart-items]'
|
|
358
|
-
);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
807
|
+
// Set state attribute for CSS styling (e.g., Tailwind variants)
|
|
808
|
+
_.setAttribute('state', hasVisibleItems ? 'has-items' : 'empty');
|
|
361
809
|
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
const hasVisibleItems = visibleItems.length > 0;
|
|
810
|
+
const hasItemsSection = _.querySelector('[data-cart-has-items]');
|
|
811
|
+
const emptySection = _.querySelector('[data-cart-is-empty]');
|
|
365
812
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
emptySection.style.display = 'none';
|
|
370
|
-
} else {
|
|
371
|
-
hasItemsSection.style.display = 'none';
|
|
372
|
-
emptySection.style.display = '';
|
|
813
|
+
if (hasItemsSection && emptySection) {
|
|
814
|
+
hasItemsSection.style.display = hasVisibleItems ? '' : 'none';
|
|
815
|
+
emptySection.style.display = hasVisibleItems ? 'none' : '';
|
|
373
816
|
}
|
|
374
817
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
this.#renderCartSubtotal(cartData);
|
|
818
|
+
_.#renderCartCount(cartData);
|
|
819
|
+
_.#renderCartSubtotal(cartData);
|
|
378
820
|
}
|
|
379
821
|
|
|
380
822
|
/**
|
|
381
|
-
*
|
|
382
|
-
* @
|
|
823
|
+
* Render cart items with smart add/update/remove
|
|
824
|
+
* @private
|
|
383
825
|
*/
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
credentials: 'same-origin',
|
|
388
|
-
})
|
|
389
|
-
.then((response) => {
|
|
390
|
-
if (!response.ok) {
|
|
391
|
-
throw Error(response.statusText);
|
|
392
|
-
}
|
|
393
|
-
return response.json();
|
|
394
|
-
})
|
|
395
|
-
.catch((error) => {
|
|
396
|
-
console.error('Error fetching cart:', error);
|
|
397
|
-
return { error: true, message: error.message };
|
|
398
|
-
});
|
|
399
|
-
}
|
|
826
|
+
#renderCartItems(cartData) {
|
|
827
|
+
const _ = this;
|
|
828
|
+
const itemsContainer = _.querySelector('[data-content-cart-items]');
|
|
400
829
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
method: 'POST',
|
|
411
|
-
credentials: 'same-origin',
|
|
412
|
-
body: JSON.stringify({ id: key, quantity: quantity }),
|
|
413
|
-
headers: { 'Content-Type': 'application/json' },
|
|
414
|
-
})
|
|
415
|
-
.then((response) => {
|
|
416
|
-
if (!response.ok) {
|
|
417
|
-
throw Error(response.statusText);
|
|
418
|
-
}
|
|
419
|
-
return response.json();
|
|
420
|
-
})
|
|
421
|
-
.catch((error) => {
|
|
422
|
-
console.error('Error updating cart item:', error);
|
|
423
|
-
return { error: true, message: error.message };
|
|
830
|
+
if (!itemsContainer || !cartData || !cartData.items) return;
|
|
831
|
+
|
|
832
|
+
const visibleItems = _.#getVisibleCartItems(cartData);
|
|
833
|
+
|
|
834
|
+
// Initial render - load all items without animation
|
|
835
|
+
if (_.#isInitialRender) {
|
|
836
|
+
itemsContainer.innerHTML = '';
|
|
837
|
+
visibleItems.forEach((itemData) => {
|
|
838
|
+
itemsContainer.appendChild(new CartItem(itemData, cartData));
|
|
424
839
|
});
|
|
425
|
-
|
|
840
|
+
_.#isInitialRender = false;
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
426
843
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
* @returns {Promise<Object>} Cart data object
|
|
431
|
-
*/
|
|
432
|
-
refreshCart(cartObj = null) {
|
|
433
|
-
// If cart object is provided, use it directly
|
|
434
|
-
if (cartObj && !cartObj.error) {
|
|
435
|
-
// console.log('Using provided cart data:', cartObj);
|
|
436
|
-
this.#currentCart = cartObj;
|
|
437
|
-
this.#renderCartItems(cartObj);
|
|
438
|
-
this.#renderCartPanel(cartObj);
|
|
844
|
+
// Get current DOM items
|
|
845
|
+
const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
|
|
846
|
+
const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
|
|
439
847
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
|
|
848
|
+
// Get new cart data keys
|
|
849
|
+
const newKeys = visibleItems.map((item) => item.key || item.id);
|
|
850
|
+
const newKeysSet = new Set(newKeys);
|
|
444
851
|
|
|
445
|
-
|
|
446
|
-
|
|
852
|
+
// Step 1: Remove items no longer in cart
|
|
853
|
+
_.#removeItemsFromDOM(itemsContainer, newKeysSet);
|
|
447
854
|
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
// Emit cart refreshed and data changed events
|
|
457
|
-
const cartWithCalculatedFields = this.#addCalculatedFields(cartData);
|
|
458
|
-
this.#emit('cart-dialog:refreshed', { cart: cartWithCalculatedFields });
|
|
459
|
-
this.#emit('cart-dialog:data-changed', cartWithCalculatedFields);
|
|
460
|
-
} else {
|
|
461
|
-
console.warn('Cart data has error or is null:', cartData);
|
|
462
|
-
}
|
|
463
|
-
return cartData;
|
|
464
|
-
});
|
|
855
|
+
// Step 2: Update existing items
|
|
856
|
+
_.#updateItemsInDOM(itemsContainer, cartData);
|
|
857
|
+
|
|
858
|
+
// Step 3: Add new items with animation
|
|
859
|
+
const itemsToAdd = visibleItems.filter(
|
|
860
|
+
(itemData) => !currentKeys.has(itemData.key || itemData.id)
|
|
861
|
+
);
|
|
862
|
+
_.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys, cartData);
|
|
465
863
|
}
|
|
466
864
|
|
|
467
865
|
/**
|
|
468
|
-
* Remove items from DOM that are no longer in cart
|
|
866
|
+
* Remove items from DOM that are no longer in cart
|
|
469
867
|
* @private
|
|
470
868
|
*/
|
|
471
869
|
#removeItemsFromDOM(itemsContainer, newKeysSet) {
|
|
472
870
|
const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
|
|
473
|
-
|
|
474
871
|
const itemsToRemove = currentItems.filter((item) => !newKeysSet.has(item.getAttribute('key')));
|
|
475
872
|
|
|
476
873
|
itemsToRemove.forEach((item) => {
|
|
477
|
-
console.log('destroy yourself', item);
|
|
478
874
|
item.destroyYourself();
|
|
479
875
|
});
|
|
480
876
|
}
|
|
481
877
|
|
|
482
878
|
/**
|
|
483
|
-
* Update existing cart-item elements with fresh
|
|
879
|
+
* Update existing cart-item elements with fresh data
|
|
484
880
|
* @private
|
|
485
881
|
*/
|
|
486
882
|
#updateItemsInDOM(itemsContainer, cartData) {
|
|
@@ -490,12 +886,7 @@ class CartDialog extends HTMLElement {
|
|
|
490
886
|
existingItems.forEach((cartItemEl) => {
|
|
491
887
|
const key = cartItemEl.getAttribute('key');
|
|
492
888
|
const updatedItemData = visibleItems.find((item) => (item.key || item.id) === key);
|
|
493
|
-
|
|
494
|
-
if (updatedItemData) {
|
|
495
|
-
// Update cart-item with fresh data and full cart context
|
|
496
|
-
// The cart-item will handle HTML comparison and only re-render if needed
|
|
497
|
-
cartItemEl.setData(updatedItemData, cartData);
|
|
498
|
-
}
|
|
889
|
+
if (updatedItemData) cartItemEl.setData(updatedItemData, cartData);
|
|
499
890
|
});
|
|
500
891
|
}
|
|
501
892
|
|
|
@@ -503,19 +894,15 @@ class CartDialog extends HTMLElement {
|
|
|
503
894
|
* Add new items to DOM with animation delay
|
|
504
895
|
* @private
|
|
505
896
|
*/
|
|
506
|
-
#addItemsToDOM(itemsContainer, itemsToAdd, newKeys) {
|
|
507
|
-
// Delay adding new items by 300ms to let cart slide open first
|
|
897
|
+
#addItemsToDOM(itemsContainer, itemsToAdd, newKeys, cartData) {
|
|
508
898
|
setTimeout(() => {
|
|
509
899
|
itemsToAdd.forEach((itemData) => {
|
|
510
|
-
const cartItem
|
|
900
|
+
const cartItem = CartItem.createAnimated(itemData, cartData);
|
|
511
901
|
const targetIndex = newKeys.indexOf(itemData.key || itemData.id);
|
|
512
902
|
|
|
513
|
-
// Find the correct position to insert the new item
|
|
514
903
|
if (targetIndex === 0) {
|
|
515
|
-
|
|
516
|
-
itemsContainer.insertBefore(cartItem$1, itemsContainer.firstChild);
|
|
904
|
+
itemsContainer.insertBefore(cartItem, itemsContainer.firstChild);
|
|
517
905
|
} else {
|
|
518
|
-
// Find the item that should come before this one
|
|
519
906
|
let insertAfter = null;
|
|
520
907
|
for (let i = targetIndex - 1; i >= 0; i--) {
|
|
521
908
|
const prevKey = newKeys[i];
|
|
@@ -527,262 +914,59 @@ class CartDialog extends HTMLElement {
|
|
|
527
914
|
}
|
|
528
915
|
|
|
529
916
|
if (insertAfter) {
|
|
530
|
-
insertAfter.insertAdjacentElement('afterend', cartItem
|
|
917
|
+
insertAfter.insertAdjacentElement('afterend', cartItem);
|
|
531
918
|
} else {
|
|
532
|
-
itemsContainer.appendChild(cartItem
|
|
919
|
+
itemsContainer.appendChild(cartItem);
|
|
533
920
|
}
|
|
534
921
|
}
|
|
535
922
|
});
|
|
536
923
|
}, 100);
|
|
537
924
|
}
|
|
538
925
|
|
|
926
|
+
// =========================================================================
|
|
927
|
+
// Private Methods - Helpers
|
|
928
|
+
// =========================================================================
|
|
929
|
+
|
|
539
930
|
/**
|
|
540
|
-
* Filter cart items to exclude
|
|
931
|
+
* Filter cart items to exclude hidden items
|
|
541
932
|
* @private
|
|
542
933
|
*/
|
|
543
934
|
#getVisibleCartItems(cartData) {
|
|
544
935
|
if (!cartData || !cartData.items) return [];
|
|
545
936
|
return cartData.items.filter((item) => {
|
|
546
|
-
// Check for _hide_in_cart in various possible locations
|
|
547
937
|
const hidden = item.properties?._hide_in_cart;
|
|
548
|
-
|
|
549
938
|
return !hidden;
|
|
550
939
|
});
|
|
551
940
|
}
|
|
552
941
|
|
|
553
942
|
/**
|
|
554
|
-
* Add calculated fields to cart object
|
|
943
|
+
* Add calculated fields to cart object
|
|
555
944
|
* @private
|
|
556
945
|
*/
|
|
557
946
|
#addCalculatedFields(cartData) {
|
|
558
947
|
if (!cartData) return cartData;
|
|
559
948
|
|
|
560
|
-
// For display counts: use visible items (excludes _hide_in_cart)
|
|
561
949
|
const visibleItems = this.#getVisibleCartItems(cartData);
|
|
562
950
|
const calculated_count = visibleItems.reduce((total, item) => total + item.quantity, 0);
|
|
563
951
|
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
const ignorePrice = item.properties?._ignore_price_in_subtotal;
|
|
567
|
-
return !ignorePrice;
|
|
568
|
-
});
|
|
569
|
-
const calculated_subtotal = pricedItems.reduce(
|
|
570
|
-
(total, item) => total + (item.line_price || 0),
|
|
571
|
-
0
|
|
572
|
-
);
|
|
573
|
-
|
|
574
|
-
return {
|
|
575
|
-
...cartData,
|
|
576
|
-
calculated_count,
|
|
577
|
-
calculated_subtotal,
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Render cart items from Shopify cart data with smart comparison
|
|
583
|
-
* @private
|
|
584
|
-
*/
|
|
585
|
-
#renderCartItems(cartData) {
|
|
586
|
-
const itemsContainer = this.querySelector('[data-content-cart-items]');
|
|
587
|
-
|
|
588
|
-
if (!itemsContainer || !cartData || !cartData.items) {
|
|
589
|
-
console.warn('Cannot render cart items:', {
|
|
590
|
-
itemsContainer: !!itemsContainer,
|
|
591
|
-
cartData: !!cartData,
|
|
592
|
-
items: cartData?.items?.length,
|
|
593
|
-
});
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Filter out items with _hide_in_cart property
|
|
598
|
-
const visibleItems = this.#getVisibleCartItems(cartData);
|
|
599
|
-
|
|
600
|
-
// Handle initial render - load all items without animation
|
|
601
|
-
if (this.#isInitialRender) {
|
|
602
|
-
// console.log('Initial cart render:', visibleItems.length, 'visible items');
|
|
603
|
-
|
|
604
|
-
// Clear existing items
|
|
605
|
-
itemsContainer.innerHTML = '';
|
|
606
|
-
|
|
607
|
-
// Create cart-item elements without animation
|
|
608
|
-
visibleItems.forEach((itemData) => {
|
|
609
|
-
const cartItem$1 = new cartItem.CartItem(itemData); // No animation
|
|
610
|
-
itemsContainer.appendChild(cartItem$1);
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
this.#isInitialRender = false;
|
|
952
|
+
const pricedItems = cartData.items.filter((item) => !item.properties?._ignore_price_in_subtotal);
|
|
953
|
+
const calculated_subtotal = pricedItems.reduce((total, item) => total + (item.line_price || 0), 0);
|
|
614
954
|
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Get current DOM items and their keys
|
|
619
|
-
const currentItems = Array.from(itemsContainer.querySelectorAll('cart-item'));
|
|
620
|
-
const currentKeys = new Set(currentItems.map((item) => item.getAttribute('key')));
|
|
621
|
-
|
|
622
|
-
// Get new cart data keys in order (only visible items)
|
|
623
|
-
const newKeys = visibleItems.map((item) => item.key || item.id);
|
|
624
|
-
const newKeysSet = new Set(newKeys);
|
|
625
|
-
|
|
626
|
-
// Step 1: Remove items that are no longer in cart data
|
|
627
|
-
this.#removeItemsFromDOM(itemsContainer, newKeysSet);
|
|
628
|
-
|
|
629
|
-
// Step 2: Update existing items with fresh data (handles templates, bundles, etc.)
|
|
630
|
-
this.#updateItemsInDOM(itemsContainer, cartData);
|
|
631
|
-
|
|
632
|
-
// Step 3: Add new items that weren't in DOM (with animation delay)
|
|
633
|
-
const itemsToAdd = visibleItems.filter(
|
|
634
|
-
(itemData) => !currentKeys.has(itemData.key || itemData.id)
|
|
635
|
-
);
|
|
636
|
-
this.#addItemsToDOM(itemsContainer, itemsToAdd, newKeys);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/**
|
|
640
|
-
* Set the template function for cart items
|
|
641
|
-
* @param {Function} templateFn - Function that takes item data and returns HTML string
|
|
642
|
-
*/
|
|
643
|
-
setCartItemTemplate(templateName, templateFn) {
|
|
644
|
-
cartItem.CartItem.setTemplate(templateName, templateFn);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Set the processing template function for cart items
|
|
649
|
-
* @param {Function} templateFn - Function that returns HTML string for processing state
|
|
650
|
-
*/
|
|
651
|
-
setCartItemProcessingTemplate(templateFn) {
|
|
652
|
-
cartItem.CartItem.setProcessingTemplate(templateFn);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* Shows the cart dialog and traps focus within it
|
|
657
|
-
* @param {HTMLElement} [triggerEl=null] - The element that triggered the cart dialog
|
|
658
|
-
* @fires CartDialog#show - Fired when the cart dialog has been shown
|
|
659
|
-
*/
|
|
660
|
-
show(triggerEl = null, cartObj) {
|
|
661
|
-
const _ = this;
|
|
662
|
-
_.triggerEl = triggerEl || false;
|
|
663
|
-
|
|
664
|
-
// Lock body scrolling
|
|
665
|
-
_.#lockScroll();
|
|
666
|
-
|
|
667
|
-
// Remove the hidden class first to ensure content is rendered
|
|
668
|
-
_.contentPanel.classList.remove('hidden');
|
|
669
|
-
|
|
670
|
-
// Give the browser a moment to process before starting animation
|
|
671
|
-
requestAnimationFrame(() => {
|
|
672
|
-
// Update ARIA states
|
|
673
|
-
_.setAttribute('aria-hidden', 'false');
|
|
674
|
-
|
|
675
|
-
if (_.triggerEl) {
|
|
676
|
-
_.triggerEl.setAttribute('aria-expanded', 'true');
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Focus management
|
|
680
|
-
const firstFocusable = _.querySelector(
|
|
681
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
682
|
-
);
|
|
683
|
-
|
|
684
|
-
if (firstFocusable) {
|
|
685
|
-
requestAnimationFrame(() => {
|
|
686
|
-
firstFocusable.focus();
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Refresh cart data when showing
|
|
691
|
-
_.refreshCart(cartObj);
|
|
692
|
-
|
|
693
|
-
// Emit show event - cart dialog is now visible
|
|
694
|
-
_.#emit('cart-dialog:show', { triggerElement: _.triggerEl });
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Hides the cart dialog and restores focus
|
|
700
|
-
* @fires CartDialog#hide - Fired when the cart dialog has started hiding (transition begins)
|
|
701
|
-
* @fires CartDialog#afterHide - Fired when the cart dialog has completed its hide transition
|
|
702
|
-
*/
|
|
703
|
-
hide() {
|
|
704
|
-
const _ = this;
|
|
705
|
-
|
|
706
|
-
// Update ARIA states
|
|
707
|
-
if (_.triggerEl) {
|
|
708
|
-
// remove focus from modal panel first
|
|
709
|
-
_.triggerEl.focus();
|
|
710
|
-
// mark trigger as no longer expanded
|
|
711
|
-
_.triggerEl.setAttribute('aria-expanded', 'false');
|
|
712
|
-
} else {
|
|
713
|
-
// If no trigger element, blur any focused element inside the panel
|
|
714
|
-
const activeElement = document.activeElement;
|
|
715
|
-
if (activeElement && _.contains(activeElement)) {
|
|
716
|
-
activeElement.blur();
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
requestAnimationFrame(() => {
|
|
721
|
-
// Set aria-hidden to start transition
|
|
722
|
-
// The transitionend event handler will add display:none when complete
|
|
723
|
-
_.setAttribute('aria-hidden', 'true');
|
|
724
|
-
|
|
725
|
-
// Emit hide event - cart dialog is now starting to hide
|
|
726
|
-
_.#emit('cart-dialog:hide', { triggerElement: _.triggerEl });
|
|
727
|
-
|
|
728
|
-
// Restore body scroll
|
|
729
|
-
_.#restoreScroll();
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/**
|
|
735
|
-
* Custom element that creates a clickable overlay for the cart dialog
|
|
736
|
-
* @extends HTMLElement
|
|
737
|
-
*/
|
|
738
|
-
class CartOverlay extends HTMLElement {
|
|
739
|
-
constructor() {
|
|
740
|
-
super();
|
|
741
|
-
this.setAttribute('tabindex', '-1');
|
|
742
|
-
this.setAttribute('aria-hidden', 'true');
|
|
743
|
-
this.cartDialog = this.closest('cart-dialog');
|
|
744
|
-
this.#attachListeners();
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
#attachListeners() {
|
|
748
|
-
this.addEventListener('click', () => {
|
|
749
|
-
this.cartDialog.hide();
|
|
750
|
-
});
|
|
955
|
+
return { ...cartData, calculated_count, calculated_subtotal };
|
|
751
956
|
}
|
|
752
957
|
}
|
|
753
958
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
*/
|
|
758
|
-
class CartPanel extends HTMLElement {
|
|
759
|
-
constructor() {
|
|
760
|
-
super();
|
|
761
|
-
this.setAttribute('role', 'document');
|
|
762
|
-
}
|
|
763
|
-
}
|
|
959
|
+
// =============================================================================
|
|
960
|
+
// Register Custom Elements
|
|
961
|
+
// =============================================================================
|
|
764
962
|
|
|
765
|
-
if (!customElements.get('cart-dialog')) {
|
|
766
|
-
customElements.define('cart-dialog', CartDialog);
|
|
767
|
-
}
|
|
768
|
-
if (!customElements.get('cart-overlay')) {
|
|
769
|
-
customElements.define('cart-overlay', CartOverlay);
|
|
770
|
-
}
|
|
771
963
|
if (!customElements.get('cart-panel')) {
|
|
772
964
|
customElements.define('cart-panel', CartPanel);
|
|
773
965
|
}
|
|
774
966
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
Object.defineProperty(exports, 'CartItem', {
|
|
781
|
-
enumerable: true,
|
|
782
|
-
get: function () { return cartItem.CartItem; }
|
|
783
|
-
});
|
|
784
|
-
exports.CartDialog = CartDialog;
|
|
785
|
-
exports.CartOverlay = CartOverlay;
|
|
967
|
+
exports.CartItem = CartItem;
|
|
968
|
+
exports.CartItemContent = CartItemContent;
|
|
969
|
+
exports.CartItemProcessing = CartItemProcessing;
|
|
786
970
|
exports.CartPanel = CartPanel;
|
|
787
|
-
exports.default =
|
|
971
|
+
exports.default = CartPanel;
|
|
788
972
|
//# sourceMappingURL=cart-panel.cjs.js.map
|