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