@magic-spells/cart-progress-bar 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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ A beautiful, accessible cart progress bar web component for free shipping thresh
6
6
 
7
7
  ## Features
8
8
 
9
- - 🎯 **Smart Messaging** - Template-based messages with automatic currency formatting
9
+ - 🎯 **Smart Messaging** - Template-based messages with flexible placeholder formats
10
10
  - 🎨 **Simplified Theming** - Just 5 CSS variables to control all colors and appearance
11
11
  - 📱 **Responsive** - Mobile-optimized with responsive breakpoints
12
12
  - ⚡ **Smooth Animations** - Buttery smooth transitions and completion effects
@@ -25,8 +25,8 @@ npm install @magic-spells/cart-progress-bar
25
25
  threshold="75.00"
26
26
  current="25.50"
27
27
  message-above="🎉 Congratulations! You've qualified for FREE shipping!"
28
- message-below="Add { amount } more for FREE shipping!">
29
- <p data-content-cart-progress-message>Add { amount } more for FREE shipping!</p>
28
+ message-below="Add ${ amount } more for FREE shipping!">
29
+ <p data-content-cart-progress-message>Add ${ amount } more for FREE shipping!</p>
30
30
  <progress-bar></progress-bar>
31
31
  </cart-progress-bar>
32
32
  ```
@@ -54,7 +54,7 @@ const info = progressBar.getProgress();
54
54
  console.log(info.percent, info.isComplete, info.thresholdAmount);
55
55
 
56
56
  // Update message templates
57
- progressBar.setMessages('Almost there!', 'Only { amount } more to go!');
57
+ progressBar.setMessages('Almost there!', 'Only ${ amount } more to go!');
58
58
  ```
59
59
 
60
60
  ## Cart Integration
@@ -63,7 +63,7 @@ The component automatically listens for cart data changes when placed inside a `
63
63
 
64
64
  ```html
65
65
  <cart-dialog>
66
- <cart-progress-bar threshold="75.00" message-below="Add { amount } more for FREE shipping!">
66
+ <cart-progress-bar threshold="75.00" message-below="Add ${ amount } more for FREE shipping!">
67
67
  </cart-progress-bar>
68
68
  </cart-dialog>
69
69
  ```
@@ -86,7 +86,7 @@ The progress bar uses intelligent pricing calculation:
86
86
  | `threshold` | Threshold amount for free shipping | `"75.00"` |
87
87
  | `current` | Current cart amount | `"25.50"` |
88
88
  | `message-above` | Success message when threshold is reached | `"🎉 FREE shipping unlocked!"` |
89
- | `message-below` | Message template shown below the bar | `"Add { amount } more!"` |
89
+ | `message-below` | Message template shown below the bar | `"Add ${ amount } more!"` |
90
90
 
91
91
  ## Customization
92
92
 
@@ -130,21 +130,40 @@ cart-progress-bar {
130
130
 
131
131
  ## Message Templates
132
132
 
133
- Use `{ amount }` in your message templates for automatic currency formatting:
133
+ Use placeholder formats in your message templates and include the currency symbol:
134
134
 
135
135
  ```html
136
136
  <cart-progress-bar
137
137
  message-above="🎉 FREE shipping unlocked!"
138
- message-below="You need { amount } more for free shipping!">
139
- <p data-content-cart-progress-message>You need { amount } more for free shipping!</p>
138
+ message-below="You need ${ amount } more for free shipping!">
139
+ <p data-content-cart-progress-message>You need ${ amount } more for free shipping!</p>
140
140
  </cart-progress-bar>
141
141
  ```
142
142
 
143
+ ### Supported Placeholder Formats
144
+
145
+ - `{ amount }` - spaces around amount
146
+ - `{amount}` - no spaces
147
+ - `[amount]` - square brackets (with or without spaces)
148
+
149
+ ### Examples
150
+
151
+ ```html
152
+ <!-- Dollar amounts -->
153
+ <cart-progress-bar message-below="Add ${ amount } more for free shipping!">
154
+
155
+ <!-- Euro amounts -->
156
+ <cart-progress-bar message-below="Only €{amount} left to unlock free delivery!">
157
+
158
+ <!-- With square brackets -->
159
+ <cart-progress-bar message-below="Just £[amount] more to go!">
160
+ ```
161
+
143
162
  The component automatically:
144
163
 
145
- - Shows `message-below` when incomplete (with `{ amount }` replaced with remaining amount needed)
146
- - Shows `message-above` when threshold is reached (success message)
147
- - Formats the amount as USD currency using Intl.NumberFormat (removes .00 for whole dollar amounts)
164
+ - Shows `message-below` when incomplete (with placeholder replaced with remaining amount)
165
+ - Shows `message-above` when threshold is reached (success message)
166
+ - Formats amounts as minimal decimal numbers (removes .00 for whole amounts)
148
167
  - Updates messages when amounts change
149
168
  - Switches between before/after progress bar colors based on completion status
150
169
 
@@ -6,23 +6,18 @@
6
6
  class ProgressBar extends HTMLElement {
7
7
  constructor() {
8
8
  super();
9
- this.#setupProgressBar();
10
- }
11
-
12
- #setupProgressBar() {
13
- this.setAttribute('role', 'progressbar');
14
- this.setAttribute('aria-valuemin', '0');
15
- this.setAttribute('aria-valuemax', '100');
16
- this.setAttribute('aria-valuenow', '0');
17
-
18
- // Create the visual progress bar
19
- this.innerHTML = '<div class="progress-bar-fill"></div>';
9
+ const _ = this;
10
+ _.setAttribute('role', 'progressbar');
11
+ _.setAttribute('aria-valuemin', '0');
12
+ _.setAttribute('aria-valuemax', '100');
13
+ _.setAttribute('aria-valuenow', '0');
14
+ _.innerHTML = '<div class="progress-bar-fill"></div>';
20
15
  }
21
16
 
22
17
  setPercent(percent) {
23
- const clampedPercent = Math.max(0, Math.min(100, percent));
24
- this.style.setProperty('--cart-progress-percent', `${clampedPercent}%`);
25
- this.setAttribute('aria-valuenow', clampedPercent);
18
+ const p = Math.max(0, Math.min(100, percent));
19
+ this.style.setProperty('--cart-progress-percent', `${p}%`);
20
+ this.setAttribute('aria-valuenow', p);
26
21
  }
27
22
  }
28
23
 
@@ -34,303 +29,230 @@ customElements.define('progress-bar', ProgressBar);
34
29
  */
35
30
  class CartProgressBar extends HTMLElement {
36
31
  // Private fields
37
- #minAmount = 0;
38
- #currentAmount = 0;
39
- #progressPercent = 0;
40
- #originalAboveMessage = '';
41
- #originalBelowMessage = '';
42
- #progressBar = null;
43
- #messageElement = null;
44
-
45
- /**
46
- * Define which attributes should be observed for changes
47
- */
32
+ #threshold = 0;
33
+ #current = 0;
34
+ #percent = 0;
35
+ #msgAbove = '';
36
+ #msgBelow = '';
37
+ #bar = null;
38
+ #msgEl = null;
39
+ #moneyFmt = null;
40
+ #debounce = null;
41
+
48
42
  static get observedAttributes() {
49
- return ['threshold', 'current', 'message-above', 'message-below'];
43
+ return ['threshold', 'current', 'message-above', 'message-below', 'money-format'];
50
44
  }
51
45
 
52
46
  constructor() {
53
47
  super();
54
- this.#init();
55
- }
56
-
57
- #init() {
58
- // Read initial attributes
59
- this.#minAmount = parseFloat(this.getAttribute('threshold')) || 0;
60
- this.#currentAmount = parseFloat(this.getAttribute('current')) || 0;
61
-
62
- // Store original message templates
63
- this.#originalAboveMessage = this.getAttribute('message-above') || '';
64
- this.#originalBelowMessage = this.getAttribute('message-below') || '';
48
+ const _ = this;
49
+ _.#threshold = parseFloat(_.getAttribute('threshold')) || 0;
50
+ _.#current = parseFloat(_.getAttribute('current')) || 0;
51
+ _.#msgAbove = _.getAttribute('message-above') || '';
52
+ _.#msgBelow = _.getAttribute('message-below') || '';
53
+ _.#moneyFmt = _.getAttribute('money-format');
65
54
  }
66
55
 
67
56
  async connectedCallback() {
68
- // ensure the child custom element has been registered
69
57
  await customElements.whenDefined('progress-bar');
70
-
71
- if (!customElements.get('progress-bar')) {
72
- throw new Error('<progress-bar> must be registered before <cart-progress-bar> runs');
73
- }
74
-
75
58
  this.#render();
76
59
  this.#updateProgress();
77
60
  this.#attachListeners();
78
61
  }
79
62
 
80
- attributeChangedCallback(name, oldValue, newValue) {
81
- if (oldValue === newValue) return;
63
+ disconnectedCallback() {
64
+ if (this.#debounce) clearTimeout(this.#debounce);
65
+ }
66
+
67
+ attributeChangedCallback(name, oldVal, newVal) {
68
+ if (oldVal === newVal) return;
69
+ const _ = this;
82
70
 
83
71
  switch (name) {
84
72
  case 'threshold':
85
- this.#minAmount = parseFloat(newValue) || 0;
86
- this.#updateProgress();
73
+ _.#threshold = parseFloat(newVal) || 0;
74
+ _.#updateProgress();
87
75
  break;
88
76
  case 'current':
89
- this.#currentAmount = parseFloat(newValue) || 0;
90
- this.#updateProgress();
77
+ _.#current = parseFloat(newVal) || 0;
78
+ _.#updateProgress();
91
79
  break;
92
80
  case 'message-above':
93
- this.#originalAboveMessage = newValue || '';
94
- this.#updateMessages();
81
+ _.#msgAbove = newVal || '';
82
+ _.#updateMessages();
95
83
  break;
96
84
  case 'message-below':
97
- this.#originalBelowMessage = newValue || '';
98
- this.#updateMessages();
85
+ _.#msgBelow = newVal || '';
86
+ _.#updateMessages();
87
+ break;
88
+ case 'money-format':
89
+ _.#moneyFmt = newVal;
90
+ _.#updateMessages();
99
91
  break;
100
92
  }
101
93
  }
102
94
 
103
95
  #render() {
104
- // Find or create the message element
105
- this.#messageElement =
106
- this.querySelector('[data-content-cart-progress-message]') ||
107
- this.querySelector('p[data-content-cart-progress-message]');
108
-
109
- // Find existing progress bar (user can add their own)
110
- this.#progressBar = this.querySelector('progress-bar');
111
-
112
- // Create message element if it doesn't exist but we have message templates
113
- if (!this.#messageElement && (this.#originalAboveMessage || this.#originalBelowMessage)) {
114
- this.#messageElement = document.createElement('p');
115
- this.#messageElement.setAttribute('data-content-cart-progress-message', '');
116
- this.appendChild(this.#messageElement);
96
+ const _ = this;
97
+ // Find existing elements or create them
98
+ _.#msgEl = _.querySelector('[data-content-cart-progress-message]');
99
+ _.#bar = _.querySelector('progress-bar');
100
+
101
+ // Create message element if needed
102
+ if (!_.#msgEl && (_.#msgAbove || _.#msgBelow)) {
103
+ _.#msgEl = document.createElement('p');
104
+ _.#msgEl.setAttribute('data-content-cart-progress-message', '');
105
+ _.appendChild(_.#msgEl);
117
106
  }
118
107
 
119
- // Create progress bar if it doesn't exist - add it at the end (below text message)
120
- if (!this.#progressBar) {
121
- // Ensure progress-bar is defined before creating
122
- if (customElements.get('progress-bar')) {
123
- this.#progressBar = document.createElement('progress-bar');
124
- this.appendChild(this.#progressBar);
125
- } else {
126
- // Fallback: wait for definition
127
- customElements.whenDefined('progress-bar').then(() => {
128
- if (!this.#progressBar) {
129
- this.#progressBar = document.createElement('progress-bar');
130
- this.appendChild(this.#progressBar);
131
- this.#updateProgress(); // Update progress after creating
132
- }
133
- });
134
- }
108
+ // Create progress bar if needed
109
+ if (!_.#bar) {
110
+ _.#bar = document.createElement('progress-bar');
111
+ _.appendChild(_.#bar);
135
112
  }
136
113
  }
137
114
 
138
115
  #updateProgress() {
139
- if (this.#minAmount === 0) {
140
- this.#progressPercent = 100;
141
- } else {
142
- this.#progressPercent = Math.min(100, (this.#currentAmount / this.#minAmount) * 100);
143
- }
144
-
145
- // Update progress bar
146
- if (this.#progressBar) {
147
- this.#progressBar.setPercent(this.#progressPercent);
148
- }
149
-
150
- // Update messages
151
- this.#updateMessages();
152
-
153
- // Update component state
154
- this.#updateComponentState();
116
+ const _ = this;
117
+ const converted = _.#converted();
118
+ _.#percent = converted === 0 ? 100 : Math.min(100, (_.#current / converted) * 100);
119
+ if (_.#bar) _.#bar.setPercent(_.#percent);
120
+ _.#updateState(converted);
155
121
  }
156
122
 
157
123
  #attachListeners() {
158
- // Find the nearest cart-panel component
159
- const cartDialog = this.closest('cart-dialog');
160
-
161
- if (cartDialog) {
162
- // Listen for cart data changes
163
- cartDialog.addEventListener('cart-dialog:data-changed', (event) => {
164
- this.#handleCartDataChange(event);
165
- });
124
+ const _ = this;
125
+ const panel = _.closest('cart-panel');
126
+ if (panel) {
127
+ panel.addEventListener('cart-panel:data-changed', (e) => _.#onCartChange(e));
166
128
  }
167
129
  }
168
130
 
169
- #handleCartDataChange(event) {
170
- const updatedCart = event.detail;
171
-
172
- if (updatedCart) {
173
- // Use calculated_subtotal if available (handles _ignore_price_in_subtotal logic)
174
- // Otherwise fall back to total_price for backwards compatibility
175
- let currentAmount = 0;
176
-
177
- if (typeof updatedCart.calculated_subtotal !== 'undefined') {
178
- // calculated_subtotal is already in dollars, no conversion needed
179
- currentAmount = updatedCart.calculated_subtotal / 100;
180
- } else if (typeof updatedCart.total_price !== 'undefined') {
181
- // Convert from cents to dollars if needed (Shopify typically returns cents)
182
- currentAmount = updatedCart.total_price / 100;
183
- }
184
-
185
- this.setCurrentAmount(currentAmount);
186
- }
131
+ #onCartChange(event) {
132
+ const _ = this;
133
+ const cart = event.detail;
134
+ if (!cart) return;
135
+
136
+ if (_.#debounce) clearTimeout(_.#debounce);
137
+ _.#debounce = setTimeout(() => {
138
+ _.#debounce = null;
139
+ // Use calculated_subtotal if available, else total_price (both in cents)
140
+ const amt = (cart.calculated_subtotal ?? cart.total_price ?? 0) / 100;
141
+ _.setCurrentAmount(amt);
142
+ }, 100);
187
143
  }
188
144
 
189
- #updateMessages() {
190
- const isComplete = this.#currentAmount >= this.#minAmount;
191
- const remainingAmount = Math.max(0, this.#minAmount - this.#currentAmount);
192
-
193
- // Format remaining amount as currency (assuming USD for now)
194
- const formattedAmount = this.#formatCurrency(remainingAmount);
195
-
196
- // Update the single message element
197
- if (this.#messageElement) {
198
- let messageTemplate;
199
-
200
- if (isComplete && this.#originalAboveMessage) {
201
- // Show success message when complete
202
- messageTemplate = this.#originalAboveMessage;
203
- } else if (!isComplete) {
204
- // Show progress message when incomplete
205
- messageTemplate = this.#originalBelowMessage || this.#originalAboveMessage;
206
- }
207
-
208
- if (messageTemplate) {
209
- const message = messageTemplate.replace('{ amount }', formattedAmount);
210
- this.#messageElement.textContent = message;
211
- this.#messageElement.style.display = 'block';
145
+ #updateState(converted) {
146
+ const _ = this;
147
+ const complete = _.#current >= converted;
148
+ const remaining = Math.max(0, converted - _.#current);
149
+ const formatted = _.#fmtMoney(remaining);
150
+
151
+ // Update message
152
+ if (_.#msgEl) {
153
+ let tpl = complete ? _.#msgAbove : (_.#msgBelow || _.#msgAbove);
154
+ if (tpl) {
155
+ _.#msgEl.textContent = tpl.replace(/\[\s*amount\s*\]/g, formatted);
156
+ _.#msgEl.style.display = 'block';
212
157
  } else {
213
- // Hide message if no template available for current state
214
- this.#messageElement.style.display = 'none';
158
+ _.#msgEl.style.display = 'none';
215
159
  }
216
160
  }
217
- }
218
161
 
219
- #updateComponentState() {
220
- const isComplete = this.#currentAmount >= this.#minAmount;
221
- this.setAttribute('complete', isComplete.toString());
162
+ // Update complete attribute
163
+ _.setAttribute('complete', complete.toString());
222
164
  }
223
165
 
224
- #formatCurrency(amount) {
225
- // Basic currency formatting - could be enhanced with locale/currency options
226
- return new Intl.NumberFormat('en-US', {
227
- style: 'currency',
228
- currency: 'USD',
229
- minimumFractionDigits: 2,
230
- maximumFractionDigits: 2,
231
- })
232
- .format(amount)
233
- .replace('.00', '');
234
- }
166
+ #fmtMoney(amt) {
167
+ const fmt = this.#moneyFmt;
168
+ if (!fmt) return amt.toFixed(2).replace(/\.00$/, '');
235
169
 
236
- /**
237
- * Public API: Set the progress percentage directly
238
- * @param {number} percent - Progress percentage (0-100)
239
- */
240
- setPercent(percent) {
241
- const clampedPercent = Math.max(0, Math.min(100, percent));
242
- this.#progressPercent = clampedPercent;
170
+ const fixed = amt.toFixed(2);
171
+ const noDecimals = Math.round(amt).toString();
172
+ const withComma = fixed.replace('.', ',');
173
+ const noDecWithComma = noDecimals.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
243
174
 
244
- if (this.#progressBar) {
245
- this.#progressBar.setPercent(clampedPercent);
246
- }
175
+ return fmt
176
+ .replace(/\{\{\s*amount_no_decimals_with_comma_separator\s*\}\}/g, noDecWithComma)
177
+ .replace(/\{\{\s*amount_with_comma_separator\s*\}\}/g, withComma)
178
+ .replace(/\{\{\s*amount_no_decimals\s*\}\}/g, noDecimals)
179
+ .replace(/\{\{\s*amount\s*\}\}/g, fixed);
180
+ }
181
+
182
+ #converted() {
183
+ const rate = parseFloat(window.Shopify?.currency?.rate) || 1;
184
+ return this.#threshold * rate;
185
+ }
247
186
 
248
- // Calculate current amount based on percentage
249
- this.#currentAmount = (clampedPercent / 100) * this.#minAmount;
250
- this.setAttribute('current', this.#currentAmount.toString());
187
+ #updateMessages() {
188
+ this.#updateState(this.#converted());
189
+ }
251
190
 
252
- this.#updateMessages();
253
- this.#updateComponentState();
191
+ // Public API
192
+ setPercent(pct) {
193
+ const _ = this;
194
+ const p = Math.max(0, Math.min(100, pct));
195
+ _.#percent = p;
196
+ if (_.#bar) _.#bar.setPercent(p);
197
+ // Use converted threshold for multi-currency consistency
198
+ _.#current = (p / 100) * _.#converted();
199
+ _.setAttribute('current', _.#current.toString());
200
+ _.#updateState(_.#converted());
254
201
  }
255
202
 
256
- /**
257
- * Public API: Set the current cart amount
258
- * @param {number} amount - Current cart amount
259
- */
260
- setCurrentAmount(amount) {
261
- this.#currentAmount = parseFloat(amount) || 0;
262
- this.setAttribute('current', this.#currentAmount.toString());
263
- this.#updateProgress();
203
+ setCurrentAmount(amt) {
204
+ const _ = this;
205
+ _.#current = parseFloat(amt) || 0;
206
+ _.setAttribute('current', _.#current.toString());
207
+ _.#updateProgress();
264
208
  }
265
209
 
266
- /**
267
- * Public API: Set the threshold amount for free shipping
268
- * @param {number} amount - Threshold amount for free shipping
269
- */
270
- setThresholdAmount(amount) {
271
- this.#minAmount = parseFloat(amount) || 0;
272
- this.setAttribute('threshold', this.#minAmount.toString());
273
- this.#updateProgress();
210
+ setThresholdAmount(amt) {
211
+ const _ = this;
212
+ _.#threshold = parseFloat(amt) || 0;
213
+ _.setAttribute('threshold', _.#threshold.toString());
214
+ _.#updateProgress();
274
215
  }
275
216
 
276
- /**
277
- * Public API: Set the minimum amount for free shipping (deprecated - use setThresholdAmount)
278
- * @param {number} amount - Minimum amount threshold
279
- * @deprecated Use setThresholdAmount instead
280
- */
281
- setMinAmount(amount) {
282
- this.setThresholdAmount(amount);
217
+ /** @deprecated Use setThresholdAmount */
218
+ setMinAmount(amt) {
219
+ this.setThresholdAmount(amt);
283
220
  }
284
221
 
285
- /**
286
- * Public API: Get current progress information
287
- */
288
222
  getProgress() {
223
+ const _ = this;
224
+ const converted = _.#converted();
289
225
  return {
290
- currentAmount: this.#currentAmount,
291
- thresholdAmount: this.#minAmount,
292
- minAmount: this.#minAmount, // backwards compatibility
293
- remainingAmount: Math.max(0, this.#minAmount - this.#currentAmount),
294
- percent: this.#progressPercent,
295
- isComplete: this.#currentAmount >= this.#minAmount,
226
+ currentAmount: _.#current,
227
+ thresholdAmount: _.#threshold,
228
+ convertedThreshold: converted,
229
+ minAmount: _.#threshold,
230
+ remainingAmount: Math.max(0, converted - _.#current),
231
+ percent: _.#percent,
232
+ isComplete: _.#current >= converted,
233
+ currencyRate: parseFloat(window.Shopify?.currency?.rate) || 1,
296
234
  };
297
235
  }
298
236
 
299
- /**
300
- * Public API: Update message templates
301
- * @param {string} aboveMessage - Message template for above the bar
302
- * @param {string} belowMessage - Message template for below the bar
303
- */
304
- setMessages(aboveMessage = null, belowMessage = null) {
305
- if (aboveMessage !== null) {
306
- this.#originalAboveMessage = aboveMessage;
307
- this.setAttribute('message-above', aboveMessage);
237
+ setMessages(above = null, below = null) {
238
+ const _ = this;
239
+ if (above !== null) {
240
+ _.#msgAbove = above;
241
+ _.setAttribute('message-above', above);
308
242
  }
309
-
310
- if (belowMessage !== null) {
311
- this.#originalBelowMessage = belowMessage;
312
- this.setAttribute('message-below', belowMessage);
243
+ if (below !== null) {
244
+ _.#msgBelow = below;
245
+ _.setAttribute('message-below', below);
313
246
  }
314
-
315
- this.#updateMessages();
247
+ _.#updateMessages();
316
248
  }
317
249
 
318
- // Getters
319
- get currentAmount() {
320
- return this.#currentAmount;
321
- }
322
- get thresholdAmount() {
323
- return this.#minAmount;
324
- }
325
- get minAmount() {
326
- return this.#minAmount;
327
- } // backwards compatibility
328
- get percent() {
329
- return this.#progressPercent;
330
- }
331
- get isComplete() {
332
- return this.#currentAmount >= this.#minAmount;
333
- }
250
+ // Getters (with backwards compatibility)
251
+ get currentAmount() { return this.#current; }
252
+ get thresholdAmount() { return this.#threshold; }
253
+ get minAmount() { return this.#threshold; }
254
+ get percent() { return this.#percent; }
255
+ get isComplete() { return this.#current >= this.#converted(); }
334
256
  }
335
257
 
336
258
  // Define CartProgressBar custom element