@magic-spells/gift-with-purchase 0.2.0 → 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.
@@ -3,47 +3,112 @@
3
3
  * Emits gwp:added/gwp:removed/gwp:error events and broadcasts cart updates
4
4
  */
5
5
  class GiftWithPurchase extends HTMLElement {
6
- // private fields
7
6
  #threshold = 0;
8
7
  #currentAmount = 0;
9
8
  #variantId = null;
10
9
  #isActive = false;
11
10
  #isAdded = false;
12
11
  #promoEnded = false;
13
- #cartDialog = null;
14
- #boundHandleCartDataChange = null; // pre-bound listener ref for clean-up
15
- #debounceTimer = null; // debouncing cart updates
16
- #messageAbove = null; // message when threshold is met
17
- #messageBelow = null; // message when below threshold
12
+ #productAvailable = true;
13
+ #isDisabled = false;
14
+ #cartPanel = null;
15
+ #handlers = {};
16
+ #debounceTimer = null;
17
+ #attachRetryTimer = null;
18
+ #isMutating = false; // prevents overlapping cart mutations
19
+ #pendingCart = null; // stores cart snapshot during mutation for recheck
20
+ #messageAbove = null;
21
+ #messageBelow = null;
22
+ #moneyFormat = null;
18
23
 
19
24
  static get observedAttributes() {
20
- return ['threshold', 'current', 'variant-id', 'promo-ended', 'message-above', 'message-below'];
25
+ return [
26
+ 'threshold',
27
+ 'current',
28
+ 'variant-id',
29
+ 'promo-ended',
30
+ 'product-available',
31
+ 'message-above',
32
+ 'message-below',
33
+ 'money-format',
34
+ ];
21
35
  }
22
36
 
23
37
  constructor() {
24
38
  super();
25
- // read initial attributes once
26
- this.#threshold = parseFloat(this.getAttribute('threshold')) || 0;
27
- this.#currentAmount = parseFloat(this.getAttribute('current')) || 0;
28
- this.#variantId = this.getAttribute('variant-id');
29
- this.#promoEnded = this.hasAttribute('promo-ended');
30
- this.#messageAbove = this.getAttribute('message-above');
31
- this.#messageBelow = this.getAttribute('message-below');
32
- this.#boundHandleCartDataChange = this.#handleCartDataChange.bind(this);
39
+ const _ = this;
40
+ _.#threshold = parseFloat(_.getAttribute('threshold')) || 0;
41
+ _.#currentAmount = parseFloat(_.getAttribute('current')) || 0;
42
+ _.#variantId = _.getAttribute('variant-id');
43
+ _.#promoEnded = _.hasAttribute('promo-ended');
44
+ _.#productAvailable = _.#parseBooleanValue(_.getAttribute('product-available'), true);
45
+ _.#messageAbove = _.getAttribute('message-above');
46
+ _.#messageBelow = _.getAttribute('message-below');
47
+ _.#moneyFormat = _.getAttribute('money-format');
48
+ _.#handlers = { cartDataChange: _.#handleCartDataChange.bind(_) };
33
49
  }
34
50
 
35
51
  connectedCallback() {
36
- this.#render();
37
- this.#attachListeners();
52
+ const _ = this;
53
+
54
+ _.#calculateInitialState();
55
+ _.#render();
56
+ _.#updateVisualState();
57
+ _.#attachListeners();
58
+ }
59
+
60
+ #calculateInitialState() {
61
+ const _ = this;
62
+ // Calculate initial active state based on attributes (before any cart events)
63
+ const convertedThreshold = _.#getConvertedThreshold();
64
+ _.#isDisabled = _.#promoEnded || !_.#productAvailable;
65
+ _.#isActive = _.#currentAmount >= convertedThreshold && !_.#isDisabled;
38
66
  }
39
67
 
40
68
  disconnectedCallback() {
41
- if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
42
- if (this.#cartDialog)
43
- this.#cartDialog.removeEventListener(
44
- 'cart-dialog:data-changed',
45
- this.#boundHandleCartDataChange
46
- );
69
+ const _ = this;
70
+ if (_.#debounceTimer) clearTimeout(_.#debounceTimer);
71
+ if (_.#attachRetryTimer) clearTimeout(_.#attachRetryTimer);
72
+ if (_.#cartPanel) _.#cartPanel.removeEventListener('cart-panel:data-changed', _.#handlers.cartDataChange);
73
+ }
74
+
75
+ attributeChangedCallback(name, oldValue, newValue) {
76
+ const _ = this;
77
+ if (oldValue === newValue) return;
78
+
79
+ switch (name) {
80
+ case 'threshold':
81
+ _.#threshold = parseFloat(newValue) || 0;
82
+ break;
83
+ case 'current':
84
+ _.#currentAmount = parseFloat(newValue) || 0;
85
+ break;
86
+ case 'variant-id':
87
+ _.#variantId = newValue;
88
+ break;
89
+ case 'promo-ended':
90
+ _.#promoEnded = newValue !== null;
91
+ break;
92
+ case 'product-available':
93
+ _.#productAvailable = _.#parseBooleanValue(newValue, true);
94
+ break;
95
+ case 'message-above':
96
+ _.#messageAbove = newValue;
97
+ break;
98
+ case 'message-below':
99
+ _.#messageBelow = newValue;
100
+ break;
101
+ case 'money-format':
102
+ _.#moneyFormat = newValue;
103
+ break;
104
+ }
105
+
106
+ // Recalculate state and update UI if component is connected
107
+ if (_.isConnected) {
108
+ _.#calculateInitialState();
109
+ _.#updateVisualState();
110
+ _.#updateMessages();
111
+ }
47
112
  }
48
113
 
49
114
  #render() {
@@ -56,24 +121,50 @@ class GiftWithPurchase extends HTMLElement {
56
121
  this.#updateMessages();
57
122
  }
58
123
 
124
+ #formatMoney(amount) {
125
+ if (!this.#moneyFormat) return amount.toFixed(2).replace(/\.00$/, '');
126
+
127
+ const amountFixed = amount.toFixed(2);
128
+ const amountNoDecimals = Math.round(amount).toString();
129
+ const amountWithComma = amountFixed.replace('.', ',');
130
+ const amountNoDecimalsWithComma = amountNoDecimals.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
131
+
132
+ return this.#moneyFormat
133
+ .replace(/\{\{\s*amount_no_decimals_with_comma_separator\s*\}\}/g, amountNoDecimalsWithComma)
134
+ .replace(/\{\{\s*amount_with_comma_separator\s*\}\}/g, amountWithComma)
135
+ .replace(/\{\{\s*amount_no_decimals\s*\}\}/g, amountNoDecimals)
136
+ .replace(/\{\{\s*amount\s*\}\}/g, amountFixed);
137
+ }
138
+
139
+ #getConvertedThreshold() {
140
+ // Convert threshold using Shopify currency rate if available (for multi-currency stores)
141
+ const rate = parseFloat(window.Shopify?.currency?.rate) || 1;
142
+ return this.#threshold * rate;
143
+ }
144
+
145
+ #parseBooleanValue(value, defaultValue = true) {
146
+ if (value === null || typeof value === 'undefined') return defaultValue;
147
+ if (value === '') return true;
148
+ const normalized = String(value).trim().toLowerCase();
149
+ if (normalized === 'false' || normalized === '0') return false;
150
+ if (normalized === 'true' || normalized === '1') return true;
151
+ return defaultValue;
152
+ }
153
+
59
154
  #updateMessages() {
60
- const messageEl = this.querySelector('[data-content-gwp-message]');
155
+ const _ = this;
156
+ const messageEl = _.querySelector('[data-content-gwp-message]');
61
157
  if (!messageEl) return;
62
158
 
63
159
  let message = '';
64
-
65
- // console.log('updateMessages - this.#isActive', this.#isActive);
66
-
67
- if (this.#isActive && this.#messageAbove) {
68
- // set message to above threshold message
69
- message = this.#messageAbove;
70
- } else if (!this.#isActive && this.#messageBelow) {
71
- // set message to below threshold message
72
- const remaining = this.#threshold - this.#currentAmount;
73
- const formattedAmount = remaining.toFixed(2).replace(/\.00$/, '');
74
- message = this.#messageBelow
75
- .replace(/\{\s*amount\s*\}/g, formattedAmount)
76
- .replace(/\{amount\}/g, formattedAmount);
160
+ if (_.#isActive && _.#messageAbove) {
161
+ message = _.#messageAbove;
162
+ } else if (!_.#isActive && _.#messageBelow) {
163
+ const remaining = _.#getConvertedThreshold() - _.#currentAmount;
164
+ const formattedAmount = _.#formatMoney(remaining);
165
+ message = _.#messageBelow
166
+ .replace(/\[\s*amount\s*\]/g, formattedAmount)
167
+ .replace(/\[amount\]/g, formattedAmount);
77
168
  }
78
169
 
79
170
  messageEl.textContent = message;
@@ -81,214 +172,206 @@ class GiftWithPurchase extends HTMLElement {
81
172
  }
82
173
 
83
174
  #attachListeners() {
84
- // Look for cart-dialog element when attaching listeners (more reliable timing)
85
- this.#cartDialog = this.closest('cart-dialog');
86
-
87
- if (this.#cartDialog) {
88
- // console.log('cartDialog exists and is attaching events');
89
- this.#cartDialog.addEventListener(
90
- 'cart-dialog:data-changed',
91
- this.#boundHandleCartDataChange
92
- );
175
+ const _ = this;
176
+ _.#cartPanel = _.closest('cart-panel');
177
+
178
+ if (_.#cartPanel) {
179
+ _.#cartPanel.addEventListener('cart-panel:data-changed', _.#handlers.cartDataChange);
93
180
  } else {
94
- // Try again after a short delay in case the DOM isn't fully ready
95
- setTimeout(() => {
96
- // console.log('cartDialog DIDNT exist and we waited to attach events');
97
- this.#cartDialog = this.closest('cart-dialog');
98
- if (this.#cartDialog) {
99
- this.#cartDialog.addEventListener(
100
- 'cart-dialog:data-changed',
101
- this.#boundHandleCartDataChange
102
- );
103
- } else {
104
- console.error('GWP - cart-dialog still not found after delay');
105
- }
181
+ _.#attachRetryTimer = setTimeout(() => {
182
+ _.#attachRetryTimer = null;
183
+ _.#cartPanel = _.closest('cart-panel');
184
+ if (_.#cartPanel) _.#cartPanel.addEventListener('cart-panel:data-changed', _.#handlers.cartDataChange);
185
+ else console.error('GWP - cart-panel still not found after delay');
106
186
  }, 100);
107
187
  }
108
188
  }
109
189
 
110
190
  #handleCartDataChange(event) {
191
+ const _ = this;
111
192
  const cart = event.detail;
112
- // console.log('GWP - handleCartDataChange cart: ', cart.calculated_subtotal, cart);
113
-
114
193
  if (!cart || typeof cart.calculated_subtotal === 'undefined') return;
115
- if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
194
+ if (_.#debounceTimer) clearTimeout(_.#debounceTimer);
116
195
 
117
- this.#debounceTimer = setTimeout(() => {
118
- this.#debounceTimer = null;
119
- this.#currentAmount = parseFloat(cart.calculated_subtotal / 100) || 0;
120
- this.#checkGiftInCart(cart);
121
- this.#updateState(cart);
196
+ _.#debounceTimer = setTimeout(() => {
197
+ _.#debounceTimer = null;
198
+ _.#currentAmount = parseFloat(cart.calculated_subtotal / 100) || 0;
199
+ _.#checkGiftInCart(cart);
200
+ _.#updateState(cart);
122
201
  }, 300);
123
202
  }
124
203
 
125
- // checks to see if the gift is already in the cart
126
204
  #checkGiftInCart(cart) {
127
- if (!cart.items || !this.#variantId) {
128
- this.#isAdded = false;
129
- return;
205
+ const _ = this;
206
+ const giftLines = _.#getGiftLines(cart, true);
207
+ _.#isAdded = giftLines.length > 0;
208
+ }
209
+
210
+ #getGiftLines(cart, matchVariantId = true) {
211
+ const _ = this;
212
+ if (!cart?.items) return [];
213
+ return cart.items.filter((item) => {
214
+ if (item.properties?._gwp_item !== 'true') return false;
215
+ if (!matchVariantId) return true;
216
+ if (!_.#variantId) return false;
217
+ return item.variant_id?.toString() === _.#variantId.toString();
218
+ });
219
+ }
220
+
221
+ #recheckIfPending() {
222
+ const _ = this;
223
+ if (_.#pendingCart) {
224
+ const cart = _.#pendingCart;
225
+ _.#pendingCart = null;
226
+ _.#checkGiftInCart(cart);
227
+ _.#updateState(cart);
130
228
  }
131
- const giftLines = cart.items.filter(
132
- (lineItem) =>
133
- lineItem.variant_id.toString() === this.#variantId.toString() &&
134
- lineItem.properties?._gwp_item === 'true'
135
- );
136
- this.#isAdded = giftLines.length > 0;
137
- if (this.#promoEnded && giftLines.length) this.#removeAllGiftItems(giftLines);
138
229
  }
139
230
 
140
231
  #updateState(cart) {
141
- // console.log('********** ---------- Updating state....');
142
- const wasActive = this.#isActive;
143
- this.#isActive = this.#currentAmount >= this.#threshold && !this.#promoEnded;
232
+ const _ = this;
144
233
 
145
- // console.log('********** ---------- this.#isActive', this.#isActive);
146
-
147
- if (this.#promoEnded) {
148
- // remove GWP from cart
149
- this.#removeGiftFromCart(cart);
234
+ // If mutation in progress, queue this cart for recheck later
235
+ if (_.#isMutating) {
236
+ _.#pendingCart = cart;
237
+ return;
150
238
  }
151
239
 
152
- if (this.#isActive && !wasActive && !this.#isAdded && this.#variantId) {
153
- this.#addGiftToCart();
154
- } else if (!this.#isActive && wasActive && this.#isAdded && this.#variantId) {
155
- this.#removeGiftFromCart(cart);
156
- }
240
+ const wasActive = _.#isActive;
241
+ const convertedThreshold = _.#getConvertedThreshold();
242
+ _.#isDisabled = _.#promoEnded || !_.#productAvailable;
243
+ _.#isActive = _.#currentAmount >= convertedThreshold && !_.#isDisabled;
157
244
 
158
- this.#updateVisualState();
159
- this.#updateMessages();
245
+ if (_.#isDisabled) _.#removeAllGiftsFromCart(cart);
246
+ else if (_.#isActive && !wasActive && !_.#isAdded && _.#variantId) _.#addGiftToCart();
247
+ else if (!_.#isActive && _.#isAdded && _.#variantId) _.#removeGiftFromCart(cart);
248
+
249
+ _.#updateVisualState();
250
+ _.#updateMessages();
160
251
  }
161
252
 
162
253
  #updateVisualState() {
163
- if (this.#promoEnded) {
164
- this.setAttribute('state', 'ended');
165
- this.style.display = 'none';
254
+ const _ = this;
255
+ if (_.#promoEnded) {
256
+ _.setAttribute('state', 'ended');
257
+ _.style.display = 'none';
166
258
  return;
167
259
  }
168
260
 
169
- this.style.display = '';
170
- if (this.#isAdded) {
171
- this.setAttribute('state', 'added');
172
- } else if (this.#isActive) {
173
- this.setAttribute('state', 'active');
261
+ if (!_.#productAvailable) {
262
+ _.setAttribute('state', 'disabled');
263
+ _.style.display = 'none';
264
+ return;
174
265
  }
175
- // Note: no 'inactive' state since component wouldn't be loaded if inactive
266
+
267
+ _.style.display = '';
268
+ if (_.#isAdded) _.setAttribute('state', 'added');
269
+ else if (_.#isActive) _.setAttribute('state', 'active');
270
+ else _.setAttribute('state', 'inactive');
176
271
  }
177
272
 
178
273
  async #addGiftToCart() {
274
+ const _ = this;
275
+ _.#isMutating = true;
179
276
  try {
180
277
  const res = await fetch('/cart/add.js', {
181
278
  method: 'POST',
182
279
  credentials: 'same-origin',
183
- headers: {
184
- 'Content-Type': 'application/json',
185
- 'X-Requested-With': 'XMLHttpRequest',
186
- },
280
+ headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
187
281
  body: JSON.stringify({
188
- items: [
189
- {
190
- id: this.#variantId,
191
- quantity: 1,
192
- properties: {
193
- _gwp_item: 'true',
194
- _hide_in_cart: 'true',
195
- _ignore_price_in_subtotal: 'true',
196
- },
197
- },
198
- ],
282
+ items: [{ id: _.#variantId, quantity: 1, properties: { _gwp_item: 'true', _hide_in_cart: 'true', _ignore_price_in_subtotal: 'true' } }],
199
283
  }),
200
284
  });
201
285
  if (!res.ok) throw new Error(`http ${res.status}`);
202
286
  await res.json();
203
- this.#isAdded = true;
204
- this.dispatchEvent(
205
- new CustomEvent('gwp:added', {
206
- detail: { variantId: this.#variantId },
207
- bubbles: true,
208
- })
209
- );
287
+ _.#isAdded = true;
288
+ _.dispatchEvent(new CustomEvent('gwp:added', { detail: { variantId: _.#variantId }, bubbles: true }));
289
+ _.#cartPanel?.getCartAndRefresh();
210
290
  } catch (err) {
211
291
  console.error('giftwithpurchase: add error', err);
212
- this.dispatchEvent(
213
- new CustomEvent('gwp:error', {
214
- detail: { action: 'add', error: err.message },
215
- bubbles: true,
216
- })
217
- );
292
+ _.dispatchEvent(new CustomEvent('gwp:error', { detail: { action: 'add', error: err.message }, bubbles: true }));
293
+ } finally {
294
+ _.#isMutating = false;
295
+ _.#recheckIfPending();
218
296
  }
219
297
  }
220
298
 
221
299
  async #removeGiftFromCart(cart) {
300
+ const _ = this;
301
+ if (!cart?.items) return;
302
+
303
+ const giftLines = _.#getGiftLines(cart, true);
304
+ if (!giftLines.length) {
305
+ _.#isAdded = false;
306
+ return;
307
+ }
308
+
309
+ _.#isMutating = true;
222
310
  try {
223
- // get all GWP items in the cart
224
- const giftLines = cart.items.filter(
225
- (lineItem) =>
226
- lineItem.variant_id.toString() === this.#variantId.toString() &&
227
- lineItem.properties?._gwp_item === 'true'
228
- );
311
+ await _.#removeAllGiftItems(giftLines);
312
+ } finally {
313
+ _.#isMutating = false;
314
+ _.#recheckIfPending();
315
+ }
316
+ }
229
317
 
230
- // exit if no items in the cart
231
- if (!giftLines.length) {
232
- this.#isAdded = false;
233
- return;
234
- }
318
+ async #removeAllGiftsFromCart(cart) {
319
+ const _ = this;
320
+ if (!cart?.items) return;
235
321
 
236
- // remove all GWP items from the cart
237
- await this.#removeAllGiftItems(giftLines);
238
- } catch (err) {
239
- console.error('giftwithpurchase: remove error', err);
240
- this.dispatchEvent(
241
- new CustomEvent('gwp:error', {
242
- detail: { action: 'remove', error: err.message },
243
- bubbles: true,
244
- })
245
- );
322
+ const giftLines = _.#getGiftLines(cart, false);
323
+ if (!giftLines.length) {
324
+ _.#isAdded = false;
325
+ return;
326
+ }
327
+
328
+ _.#isMutating = true;
329
+ try {
330
+ await _.#removeAllGiftItems(giftLines);
331
+ } finally {
332
+ _.#isMutating = false;
333
+ _.#recheckIfPending();
246
334
  }
247
335
  }
248
336
 
249
337
  async #removeAllGiftItems(giftLines) {
338
+ const _ = this;
250
339
  try {
251
340
  await Promise.all(
252
- giftLines.map((giftItem) =>
253
- fetch('/cart/change.js', {
341
+ giftLines.map(async (item) => {
342
+ const res = await fetch('/cart/change.js', {
254
343
  method: 'POST',
255
344
  credentials: 'same-origin',
256
- headers: {
257
- 'Content-Type': 'application/json',
258
- 'X-Requested-With': 'XMLHttpRequest',
259
- },
260
- body: JSON.stringify({ id: giftItem.key, quantity: 0 }),
261
- })
262
- )
263
- );
264
- this.#isAdded = false;
265
- // note: you can broadcast cart again here if you want real-time re-render after remove
266
- this.dispatchEvent(
267
- new CustomEvent('gwp:removed', {
268
- detail: { variantId: this.#variantId },
269
- bubbles: true,
345
+ headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
346
+ body: JSON.stringify({ id: item.key, quantity: 0 }),
347
+ });
348
+ if (!res.ok) throw new Error(`http ${res.status}`);
270
349
  })
271
350
  );
351
+ _.#isAdded = false;
352
+ _.dispatchEvent(new CustomEvent('gwp:removed', { detail: { variantId: _.#variantId }, bubbles: true }));
353
+ _.#cartPanel?.getCartAndRefresh();
272
354
  } catch (err) {
273
355
  console.error('giftwithpurchase: bulk remove error', err);
274
- this.dispatchEvent(
275
- new CustomEvent('gwp:error', {
276
- detail: { action: 'remove', error: err.message },
277
- bubbles: true,
278
- })
279
- );
356
+ _.dispatchEvent(new CustomEvent('gwp:error', { detail: { action: 'remove', error: err.message }, bubbles: true }));
280
357
  }
281
358
  }
282
359
 
283
360
  getState() {
361
+ const _ = this;
362
+ const convertedThreshold = _.#getConvertedThreshold();
284
363
  return {
285
- currentAmount: this.#currentAmount,
286
- threshold: this.#threshold,
287
- variantId: this.#variantId,
288
- isActive: this.#isActive,
289
- isAdded: this.#isAdded,
290
- promoEnded: this.#promoEnded,
291
- remainingAmount: Math.max(0, this.#threshold - this.#currentAmount),
364
+ currentAmount: _.#currentAmount,
365
+ threshold: _.#threshold,
366
+ convertedThreshold,
367
+ variantId: _.#variantId,
368
+ isActive: _.#isActive,
369
+ isAdded: _.#isAdded,
370
+ promoEnded: _.#promoEnded,
371
+ productAvailable: _.#productAvailable,
372
+ isDisabled: _.#isDisabled,
373
+ remainingAmount: Math.max(0, convertedThreshold - _.#currentAmount),
374
+ currencyRate: parseFloat(window.Shopify?.currency?.rate) || 1,
292
375
  };
293
376
  }
294
377
 
@@ -310,18 +393,25 @@ class GiftWithPurchase extends HTMLElement {
310
393
  get promoEnded() {
311
394
  return this.#promoEnded;
312
395
  }
396
+ get productAvailable() {
397
+ return this.#productAvailable;
398
+ }
399
+ get isDisabled() {
400
+ return this.#isDisabled;
401
+ }
313
402
 
314
- // Public setter methods for programmatic control
315
403
  setCurrentAmount(amount) {
316
- this.#currentAmount = parseFloat(amount) || 0;
317
- this.#updateState({ items: [] }); // Pass empty cart to avoid cart operations
318
- this.#updateMessages();
404
+ const _ = this;
405
+ _.#currentAmount = parseFloat(amount) || 0;
406
+ _.#updateState({ items: [] });
407
+ _.#updateMessages();
319
408
  }
320
409
 
321
410
  setThreshold(threshold) {
322
- this.#threshold = parseFloat(threshold) || 0;
323
- this.#updateState({ items: [] }); // Pass empty cart to avoid cart operations
324
- this.#updateMessages();
411
+ const _ = this;
412
+ _.#threshold = parseFloat(threshold) || 0;
413
+ _.#updateState({ items: [] });
414
+ _.#updateMessages();
325
415
  }
326
416
 
327
417
  setVariantId(variantId) {