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