@magic-spells/gift-with-purchase 0.2.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.
@@ -0,0 +1,338 @@
1
+ import './gift-with-purchase.scss';
2
+
3
+ /**
4
+ * Gift With Purchase Component - automatically adds/removes gift when cart threshold is met
5
+ * Emits gwp:added/gwp:removed/gwp:error events and broadcasts cart updates
6
+ */
7
+ class GiftWithPurchase extends HTMLElement {
8
+ // private fields
9
+ #threshold = 0;
10
+ #currentAmount = 0;
11
+ #variantId = null;
12
+ #isActive = false;
13
+ #isAdded = false;
14
+ #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
20
+
21
+ static get observedAttributes() {
22
+ return ['threshold', 'current', 'variant-id', 'promo-ended', 'message-above', 'message-below'];
23
+ }
24
+
25
+ constructor() {
26
+ 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);
35
+ }
36
+
37
+ connectedCallback() {
38
+ this.#render();
39
+ this.#attachListeners();
40
+ }
41
+
42
+ 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
+ );
49
+ }
50
+
51
+ #render() {
52
+ this.classList.add('gift-with-purchase');
53
+ this.#renderMessages();
54
+ }
55
+
56
+ #renderMessages() {
57
+ // Look for existing message element with data-content-gwp-message
58
+ this.#updateMessages();
59
+ }
60
+
61
+ #updateMessages() {
62
+ const messageEl = this.querySelector('[data-content-gwp-message]');
63
+ if (!messageEl) return;
64
+
65
+ 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);
79
+ }
80
+
81
+ messageEl.textContent = message;
82
+ messageEl.style.display = message ? 'block' : 'none';
83
+ }
84
+
85
+ #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
+ );
95
+ } 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
+ }
108
+ }, 100);
109
+ }
110
+ }
111
+
112
+ #handleCartDataChange(event) {
113
+ const cart = event.detail;
114
+ // console.log('GWP - handleCartDataChange cart: ', cart.calculated_subtotal, cart);
115
+
116
+ if (!cart || typeof cart.calculated_subtotal === 'undefined') return;
117
+ if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
118
+
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);
124
+ }, 300);
125
+ }
126
+
127
+ // checks to see if the gift is already in the cart
128
+ #checkGiftInCart(cart) {
129
+ if (!cart.items || !this.#variantId) {
130
+ this.#isAdded = false;
131
+ return;
132
+ }
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
+ }
141
+
142
+ #updateState(cart) {
143
+ // console.log('********** ---------- Updating state....');
144
+ const wasActive = this.#isActive;
145
+ this.#isActive = this.#currentAmount >= this.#threshold && !this.#promoEnded;
146
+
147
+ // console.log('********** ---------- this.#isActive', this.#isActive);
148
+
149
+ if (this.#promoEnded) {
150
+ // remove GWP from cart
151
+ this.#removeGiftFromCart(cart);
152
+ }
153
+
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
+ }
159
+
160
+ this.#updateVisualState();
161
+ this.#updateMessages();
162
+ }
163
+
164
+ #updateVisualState() {
165
+ if (this.#promoEnded) {
166
+ this.setAttribute('state', 'ended');
167
+ this.style.display = 'none';
168
+ return;
169
+ }
170
+
171
+ this.style.display = '';
172
+ if (this.#isAdded) {
173
+ this.setAttribute('state', 'added');
174
+ } else if (this.#isActive) {
175
+ this.setAttribute('state', 'active');
176
+ }
177
+ // Note: no 'inactive' state since component wouldn't be loaded if inactive
178
+ }
179
+
180
+ async #addGiftToCart() {
181
+ try {
182
+ const res = await fetch('/cart/add.js', {
183
+ method: 'POST',
184
+ credentials: 'same-origin',
185
+ headers: {
186
+ 'Content-Type': 'application/json',
187
+ 'X-Requested-With': 'XMLHttpRequest',
188
+ },
189
+ 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
+ ],
201
+ }),
202
+ });
203
+ if (!res.ok) throw new Error(`http ${res.status}`);
204
+ 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
+ );
212
+ } catch (err) {
213
+ 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
+ );
220
+ }
221
+ }
222
+
223
+ async #removeGiftFromCart(cart) {
224
+ 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
+ );
231
+
232
+ // exit if no items in the cart
233
+ if (!giftLines.length) {
234
+ this.#isAdded = false;
235
+ return;
236
+ }
237
+
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
+ );
248
+ }
249
+ }
250
+
251
+ async #removeAllGiftItems(giftLines) {
252
+ try {
253
+ await Promise.all(
254
+ giftLines.map((giftItem) =>
255
+ fetch('/cart/change.js', {
256
+ method: 'POST',
257
+ 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,
272
+ })
273
+ );
274
+ } catch (err) {
275
+ 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
+ );
282
+ }
283
+ }
284
+
285
+ getState() {
286
+ 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),
294
+ };
295
+ }
296
+
297
+ get currentAmount() {
298
+ return this.#currentAmount;
299
+ }
300
+ get threshold() {
301
+ return this.#threshold;
302
+ }
303
+ get variantId() {
304
+ return this.#variantId;
305
+ }
306
+ get isActive() {
307
+ return this.#isActive;
308
+ }
309
+ get isAdded() {
310
+ return this.#isAdded;
311
+ }
312
+ get promoEnded() {
313
+ return this.#promoEnded;
314
+ }
315
+
316
+ // Public setter methods for programmatic control
317
+ setCurrentAmount(amount) {
318
+ this.#currentAmount = parseFloat(amount) || 0;
319
+ this.#updateState({ items: [] }); // Pass empty cart to avoid cart operations
320
+ this.#updateMessages();
321
+ }
322
+
323
+ setThreshold(threshold) {
324
+ this.#threshold = parseFloat(threshold) || 0;
325
+ this.#updateState({ items: [] }); // Pass empty cart to avoid cart operations
326
+ this.#updateMessages();
327
+ }
328
+
329
+ setVariantId(variantId) {
330
+ this.#variantId = variantId;
331
+ }
332
+ }
333
+
334
+ if (!customElements.get('gift-with-purchase')) {
335
+ customElements.define('gift-with-purchase', GiftWithPurchase);
336
+ }
337
+
338
+ export { GiftWithPurchase };
@@ -0,0 +1,70 @@
1
+ // SCSS Variables (can be overridden before import)
2
+ $gwp-border-radius: 8px !default;
3
+ $gwp-padding: 1rem !default;
4
+ $gwp-gap: 1rem !default;
5
+
6
+ // Colors
7
+ $gwp-bg-active: #e8f5e8 !default;
8
+ $gwp-bg-added: #d4edda !default;
9
+ $gwp-border-active: #28a745 !default;
10
+ $gwp-border-added: #155724 !default;
11
+ $gwp-text-active: #155724 !default;
12
+ $gwp-text-added: #155724 !default;
13
+
14
+ // Gift with purchase component styles
15
+ gift-with-purchase {
16
+ // CSS Custom Properties for customization
17
+ --gwp-border-radius: #{$gwp-border-radius};
18
+ --gwp-padding: #{$gwp-padding};
19
+ --gwp-gap: #{$gwp-gap};
20
+ --gwp-bg-active: #{$gwp-bg-active};
21
+ --gwp-bg-added: #{$gwp-bg-added};
22
+ --gwp-border-active: #{$gwp-border-active};
23
+ --gwp-border-added: #{$gwp-border-added};
24
+ --gwp-text-active: #{$gwp-text-active};
25
+ --gwp-text-added: #{$gwp-text-added};
26
+
27
+ display: block;
28
+ border: 2px solid var(--gwp-border-active);
29
+ border-radius: var(--gwp-border-radius);
30
+ padding: var(--gwp-padding);
31
+ background-color: var(--gwp-bg-active);
32
+ color: var(--gwp-text-active);
33
+
34
+ // Basic product layout - image left, content right
35
+ .gwp-product {
36
+ display: flex;
37
+ align-items: flex-start;
38
+ gap: var(--gwp-gap);
39
+ }
40
+
41
+ // Basic image styling
42
+ [data-gwp-image] {
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ // Content takes remaining space
47
+ .gwp-content {
48
+ flex: 1;
49
+ min-width: 0;
50
+ }
51
+
52
+ // State: Active (threshold reached, gift not yet added)
53
+ &[state='active'] {
54
+ background-color: var(--gwp-bg-active);
55
+ border-color: var(--gwp-border-active);
56
+ color: var(--gwp-text-active);
57
+ }
58
+
59
+ // State: Added (gift in cart)
60
+ &[state='added'] {
61
+ background-color: var(--gwp-bg-added);
62
+ border-color: var(--gwp-border-added);
63
+ color: var(--gwp-text-added);
64
+ }
65
+
66
+ // State: Ended (promo ended - component hidden)
67
+ &[state='ended'] {
68
+ display: none;
69
+ }
70
+ }