@o2vend/theme-cli 1.0.32

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.
Files changed (116) hide show
  1. package/README.md +425 -0
  2. package/assets/Logo_o2vend.png +0 -0
  3. package/assets/favicon.png +0 -0
  4. package/assets/logo-white.png +0 -0
  5. package/bin/o2vend +42 -0
  6. package/config/widget-map.json +50 -0
  7. package/lib/commands/check.js +201 -0
  8. package/lib/commands/generate.js +33 -0
  9. package/lib/commands/init.js +214 -0
  10. package/lib/commands/optimize.js +216 -0
  11. package/lib/commands/package.js +208 -0
  12. package/lib/commands/serve.js +105 -0
  13. package/lib/commands/validate.js +191 -0
  14. package/lib/lib/api-client.js +357 -0
  15. package/lib/lib/dev-server.js +2618 -0
  16. package/lib/lib/file-watcher.js +80 -0
  17. package/lib/lib/hot-reload.js +106 -0
  18. package/lib/lib/liquid-engine.js +822 -0
  19. package/lib/lib/liquid-filters.js +671 -0
  20. package/lib/lib/mock-api-server.js +989 -0
  21. package/lib/lib/mock-data.js +1468 -0
  22. package/lib/lib/widget-service.js +321 -0
  23. package/package.json +70 -0
  24. package/test-theme/README.md +27 -0
  25. package/test-theme/assets/async-sections.js +446 -0
  26. package/test-theme/assets/cart-drawer.js +463 -0
  27. package/test-theme/assets/cart-manager.js +223 -0
  28. package/test-theme/assets/checkout-price-handler.js +368 -0
  29. package/test-theme/assets/components.css +4629 -0
  30. package/test-theme/assets/delivery-zone.css +299 -0
  31. package/test-theme/assets/delivery-zone.js +396 -0
  32. package/test-theme/assets/logo.png +0 -0
  33. package/test-theme/assets/sections.css +48 -0
  34. package/test-theme/assets/theme.css +3500 -0
  35. package/test-theme/assets/theme.js +3745 -0
  36. package/test-theme/config/settings_data.json +292 -0
  37. package/test-theme/config/settings_schema.json +1050 -0
  38. package/test-theme/layout/theme.liquid +195 -0
  39. package/test-theme/locales/en.default.json +260 -0
  40. package/test-theme/sections/content-fallback.liquid +53 -0
  41. package/test-theme/sections/content.liquid +57 -0
  42. package/test-theme/sections/footer-fallback.liquid +328 -0
  43. package/test-theme/sections/footer.liquid +278 -0
  44. package/test-theme/sections/header-fallback.liquid +1805 -0
  45. package/test-theme/sections/header.liquid +1145 -0
  46. package/test-theme/sections/hero-fallback.liquid +212 -0
  47. package/test-theme/sections/hero.liquid +136 -0
  48. package/test-theme/snippets/account-sidebar.liquid +200 -0
  49. package/test-theme/snippets/add-to-cart-modal.liquid +484 -0
  50. package/test-theme/snippets/breadcrumbs.liquid +134 -0
  51. package/test-theme/snippets/cart-drawer.liquid +467 -0
  52. package/test-theme/snippets/delivery-zone-city-selector.liquid +79 -0
  53. package/test-theme/snippets/delivery-zone-modal.liquid +337 -0
  54. package/test-theme/snippets/delivery-zone-search.liquid +78 -0
  55. package/test-theme/snippets/icon.liquid +105 -0
  56. package/test-theme/snippets/login-modal.liquid +346 -0
  57. package/test-theme/snippets/mega-menu.liquid +812 -0
  58. package/test-theme/snippets/news-thumbnail.liquid +187 -0
  59. package/test-theme/snippets/pagination.liquid +120 -0
  60. package/test-theme/snippets/price.liquid +92 -0
  61. package/test-theme/snippets/product-card-related.liquid +78 -0
  62. package/test-theme/snippets/product-card-simple.liquid +41 -0
  63. package/test-theme/snippets/product-card.liquid +697 -0
  64. package/test-theme/snippets/rating.liquid +85 -0
  65. package/test-theme/snippets/skeleton-collection-grid.liquid +114 -0
  66. package/test-theme/snippets/skeleton-product-card.liquid +124 -0
  67. package/test-theme/snippets/skeleton-product-grid.liquid +34 -0
  68. package/test-theme/snippets/social-sharing.liquid +185 -0
  69. package/test-theme/templates/account/dashboard.liquid +401 -0
  70. package/test-theme/templates/account/loyalty-redemption.liquid +405 -0
  71. package/test-theme/templates/account/loyalty.liquid +588 -0
  72. package/test-theme/templates/account/order-detail.liquid +230 -0
  73. package/test-theme/templates/account/orders.liquid +349 -0
  74. package/test-theme/templates/account/profile.liquid +758 -0
  75. package/test-theme/templates/account/register.liquid +232 -0
  76. package/test-theme/templates/account/return-orders.liquid +348 -0
  77. package/test-theme/templates/account/store-credit.liquid +464 -0
  78. package/test-theme/templates/account/subscriptions.liquid +601 -0
  79. package/test-theme/templates/account/wishlist.liquid +419 -0
  80. package/test-theme/templates/address-book.liquid +1092 -0
  81. package/test-theme/templates/categories.liquid +452 -0
  82. package/test-theme/templates/checkout.liquid +4511 -0
  83. package/test-theme/templates/error.liquid +384 -0
  84. package/test-theme/templates/index.liquid +11 -0
  85. package/test-theme/templates/login.liquid +185 -0
  86. package/test-theme/templates/order-confirmation.liquid +720 -0
  87. package/test-theme/templates/page.liquid +297 -0
  88. package/test-theme/templates/product-detail.liquid +4363 -0
  89. package/test-theme/templates/products.liquid +518 -0
  90. package/test-theme/templates/search.liquid +922 -0
  91. package/test-theme/theme.json.example +19 -0
  92. package/test-theme/widgets/brand-carousel.liquid +676 -0
  93. package/test-theme/widgets/brand.liquid +245 -0
  94. package/test-theme/widgets/carousel.liquid +843 -0
  95. package/test-theme/widgets/category-list-carousel.liquid +656 -0
  96. package/test-theme/widgets/category-list.liquid +340 -0
  97. package/test-theme/widgets/category.liquid +475 -0
  98. package/test-theme/widgets/discount-time.liquid +176 -0
  99. package/test-theme/widgets/footer-menu.liquid +695 -0
  100. package/test-theme/widgets/footer.liquid +179 -0
  101. package/test-theme/widgets/gallery.liquid +271 -0
  102. package/test-theme/widgets/header-menu.liquid +932 -0
  103. package/test-theme/widgets/header.liquid +159 -0
  104. package/test-theme/widgets/html.liquid +214 -0
  105. package/test-theme/widgets/news.liquid +217 -0
  106. package/test-theme/widgets/product-canvas.liquid +235 -0
  107. package/test-theme/widgets/product-carousel.liquid +502 -0
  108. package/test-theme/widgets/product.liquid +45 -0
  109. package/test-theme/widgets/recently-viewed.liquid +26 -0
  110. package/test-theme/widgets/shared/product-grid.liquid +339 -0
  111. package/test-theme/widgets/simple-product.liquid +42 -0
  112. package/test-theme/widgets/single-product.liquid +610 -0
  113. package/test-theme/widgets/spacebar-carousel.liquid +663 -0
  114. package/test-theme/widgets/spacebar.liquid +279 -0
  115. package/test-theme/widgets/splash.liquid +378 -0
  116. package/test-theme/widgets/testimonial-carousel.liquid +709 -0
@@ -0,0 +1,463 @@
1
+ ;(function(){
2
+ console.log('[Cart Drawer] Script loaded')
3
+
4
+ function qs(sel, ctx){ return (ctx||document).querySelector(sel) }
5
+ function qsa(sel, ctx){ return Array.from((ctx||document).querySelectorAll(sel)) }
6
+
7
+ const bodyEl = () => document.body;
8
+ const shopCurrency = () => (bodyEl() && bodyEl().dataset.shopCurrency) || window.__SHOP_CURRENCY__ || 'USD';
9
+ const shopCurrencySymbol = () => (bodyEl() && bodyEl().dataset.shopCurrencySymbol) || window.__SHOP_CURRENCY_SYMBOL__ || shopCurrency();
10
+ const shopLocale = () => (bodyEl() && bodyEl().dataset.shopLocale) || window.__SHOP_LOCALE__ || 'en-US';
11
+
12
+ // Format money helper
13
+ function formatMoney(amount, currency = shopCurrency()) {
14
+ const locale = shopLocale();
15
+ const value = typeof amount === 'number' ? amount : Number(amount) || 0;
16
+ const currencySymbol = shopCurrencySymbol();
17
+ const isIsoCurrency = typeof currency === 'string' && /^[A-Z]{3}$/.test(currency);
18
+
19
+ if (isIsoCurrency) {
20
+ try {
21
+ return new Intl.NumberFormat(locale, {
22
+ style: 'currency',
23
+ currency
24
+ }).format(value);
25
+ } catch (error) {
26
+ console.warn('Failed to format money with locale/currency', locale, currency, error);
27
+ }
28
+ }
29
+
30
+ // Fallback: format number with locale and prepend symbol
31
+ const formattedNumber = new Intl.NumberFormat(locale, {
32
+ minimumFractionDigits: 2,
33
+ maximumFractionDigits: 2
34
+ }).format(value);
35
+
36
+ return currencySymbol ? `${currencySymbol}${formattedNumber}` : formattedNumber;
37
+ }
38
+
39
+ function resetCheckoutButton() {
40
+ const checkoutBtn = qs('#cart-drawer-checkout-btn')
41
+ if (checkoutBtn) {
42
+ checkoutBtn.classList.remove('loading')
43
+ checkoutBtn.disabled = false
44
+ checkoutBtn.removeAttribute('aria-disabled')
45
+ // Restore original button text (stored in data attribute or default to 'Check out')
46
+ const originalText = checkoutBtn.dataset.originalText || 'Check out'
47
+ checkoutBtn.innerHTML = originalText
48
+ }
49
+ }
50
+
51
+ function openDrawer(){
52
+ const drawer = qs('#cart-drawer')
53
+ if(!drawer) return
54
+ drawer.classList.add('active')
55
+ drawer.setAttribute('aria-hidden','false')
56
+ document.body.style.overflow = 'hidden'
57
+ // Reset checkout button state when opening drawer
58
+ resetCheckoutButton()
59
+ loadCartData()
60
+ }
61
+
62
+ function closeDrawer(){
63
+ const drawer = qs('#cart-drawer')
64
+ if(!drawer) return
65
+ drawer.classList.remove('active')
66
+ drawer.setAttribute('aria-hidden','true')
67
+ document.body.style.overflow = ''
68
+ }
69
+
70
+ // Flag to prevent infinite recursion when handling cart:updated events
71
+ let _isHandlingCartEvent = false;
72
+
73
+ function updateCartCount(count, skipDispatch = false){
74
+ const numericCount = parseInt(count, 10) || 0
75
+
76
+ // If skipDispatch is true, just update the UI directly without dispatching events
77
+ // This prevents infinite recursion when called from the cart:updated event listener
78
+ if (skipDispatch) {
79
+ if (window.CartManager) {
80
+ // Only update the badge UI directly, don't dispatch another event
81
+ window.CartManager.updateCartBadge(numericCount);
82
+ } else {
83
+ // Fallback: update UI directly without dispatching
84
+ const els = qsa('[data-cart-count]')
85
+ els.forEach(el => {
86
+ el.textContent = numericCount
87
+ el.setAttribute('data-cart-count', numericCount.toString())
88
+ const isDrawerTitle = el.closest('.cart-drawer-title')
89
+ if (numericCount > 0) {
90
+ el.removeAttribute('style')
91
+ } else {
92
+ if (!isDrawerTitle) {
93
+ el.style.display = 'none'
94
+ } else {
95
+ el.removeAttribute('style')
96
+ }
97
+ }
98
+ })
99
+ }
100
+ return;
101
+ }
102
+
103
+ // Normal flow: use CartManager to dispatch event and update badges
104
+ // This ensures header badge and drawer badge stay in sync
105
+ if (window.CartManager) {
106
+ window.CartManager.dispatchCartUpdated({ itemCount: numericCount });
107
+ } else {
108
+ // Fallback if CartManager not loaded yet
109
+ const els = qsa('[data-cart-count]')
110
+ els.forEach(el => {
111
+ el.textContent = numericCount
112
+ el.setAttribute('data-cart-count', numericCount.toString())
113
+ const isDrawerTitle = el.closest('.cart-drawer-title')
114
+ if (numericCount > 0) {
115
+ el.removeAttribute('style')
116
+ } else {
117
+ // Hide header badges when count is 0, but keep drawer title visible
118
+ if (!isDrawerTitle) {
119
+ el.style.display = 'none'
120
+ } else {
121
+ el.removeAttribute('style')
122
+ }
123
+ }
124
+ })
125
+
126
+ // Dispatch event as fallback only if not already handling an event
127
+ if (!_isHandlingCartEvent) {
128
+ _isHandlingCartEvent = true;
129
+ const event = new CustomEvent('cart:updated', {
130
+ detail: {
131
+ count: numericCount
132
+ },
133
+ bubbles: true,
134
+ cancelable: true
135
+ });
136
+ document.dispatchEvent(event);
137
+ _isHandlingCartEvent = false;
138
+ }
139
+ }
140
+ }
141
+
142
+ async function loadCartData(){
143
+ const loading = qs('[data-cart-loading]')
144
+ const empty = qs('[data-cart-empty]')
145
+ const itemsList = qs('[data-cart-items-list]')
146
+ const footer = qs('[data-cart-footer]')
147
+
148
+ if (loading) loading.style.display = 'flex'
149
+ if (empty) empty.style.display = 'none'
150
+ if (itemsList) itemsList.style.display = 'none'
151
+ if (footer) footer.style.display = 'none'
152
+
153
+ // Reset checkout button state when loading cart data
154
+ resetCheckoutButton()
155
+
156
+ try {
157
+ const res = await fetch('/webstoreapi/carts')
158
+ const json = await res.json()
159
+
160
+ if (!json.success || !json.data) {
161
+ throw new Error(json.error || 'Failed to load cart')
162
+ }
163
+
164
+ const cart = json.data
165
+
166
+ if (loading) loading.style.display = 'none'
167
+
168
+ if (!cart.items || cart.items.length === 0) {
169
+ if (empty) empty.style.display = 'flex'
170
+ if (footer) footer.style.display = 'none'
171
+ } else {
172
+ if (empty) empty.style.display = 'none'
173
+ if (itemsList) {
174
+ itemsList.style.display = 'block'
175
+ renderCartItems(cart.items)
176
+ }
177
+ if (footer) {
178
+ footer.style.display = 'flex'
179
+ updateCartFooter(cart)
180
+ }
181
+ }
182
+
183
+ const count = cart.itemCount || 0
184
+
185
+ // Use CartManager as single source of truth - it will update both header and drawer badges
186
+ // updateCartCount() already dispatches the event, so we don't need to dispatch again
187
+ updateCartCount(count)
188
+ } catch (e) {
189
+ console.error('Failed to load cart:', e)
190
+ if (loading) loading.style.display = 'none'
191
+ if (empty) empty.style.display = 'flex'
192
+ if (footer) footer.style.display = 'none'
193
+ // Reset checkout button even on error
194
+ resetCheckoutButton()
195
+ }
196
+ }
197
+
198
+ function renderCartItems(items) {
199
+ const itemsList = qs('[data-cart-items-list]')
200
+ if (!itemsList) return
201
+
202
+ itemsList.innerHTML = items.map(item => {
203
+ // Check for variation image override in localStorage
204
+ const imageKey = `variantImage_${item.productId}`;
205
+ let variantImage = null;
206
+ try {
207
+ variantImage = localStorage.getItem(imageKey);
208
+ } catch (e) {
209
+ console.warn('Failed to read variant image from localStorage:', e);
210
+ }
211
+
212
+ // Use variant image if available, otherwise use API image
213
+ const displayImage = variantImage || item.image;
214
+
215
+ return `
216
+ <div class="cart-drawer-item" data-item-id="${item.productId}-${item.variantId}">
217
+ <a href="/${item.productSlug || item.productId}" class="cart-drawer-image">
218
+ ${displayImage ? `<img src="${displayImage}" alt="${item.title}" loading="lazy">` : ''}
219
+ </a>
220
+ <div class="cart-drawer-item-content">
221
+ <a href="/${item.productSlug || item.productId}" class="cart-drawer-item-title">${item.title}</a>
222
+ ${item.variantTitle && item.variantTitle !== 'Default Title' ? `<div class="cart-drawer-item-variant">${item.variantTitle}</div>` : ''}
223
+ <div class="cart-drawer-item-row">
224
+ <div class="cart-drawer-qty">
225
+ <button class="qty-btn" data-action="decrease" data-product-id="${item.productId}" data-variant-id="${item.variantId}">−</button>
226
+ <input type="number" class="qty-input" value="${item.quantity}" min="1" max="99" data-product-id="${item.productId}" data-variant-id="${item.variantId}">
227
+ <button class="qty-btn" data-action="increase" data-product-id="${item.productId}" data-variant-id="${item.variantId}">+</button>
228
+ </div>
229
+ <div class="cart-drawer-item-price">${formatMoney(item.linePrice)}</div>
230
+ </div>
231
+ </div>
232
+ <button class="cart-drawer-remove" aria-label="Remove" data-remove-item data-product-id="${item.productId}" data-variant-id="${item.variantId}">
233
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
234
+ <polyline points="3,6 5,6 21,6"></polyline>
235
+ <path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"></path>
236
+ </svg>
237
+ </button>
238
+ </div>
239
+ `;
240
+ }).join('')
241
+ }
242
+
243
+ function updateCartFooter(cart) {
244
+ const total = qs('[data-cart-total]')
245
+ if (total) {
246
+ total.textContent = formatMoney(cart.total)
247
+ }
248
+ }
249
+
250
+ async function updateQuantity(input, delta){
251
+ const qty = Math.max(1, Math.min(99, (parseInt(input.value||'1',10) || 1) + delta))
252
+ input.value = qty
253
+ await syncQuantity(input)
254
+ }
255
+
256
+ async function syncQuantity(input){
257
+ const productId = input.dataset.productId
258
+ const variantId = input.dataset.variantId
259
+ const quantity = parseInt(input.value, 10)
260
+ try {
261
+ const res = await fetch('/webstoreapi/cart/update', {
262
+ method: 'POST',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({ productId, variantId, quantity })
265
+ })
266
+ const json = await res.json()
267
+ if(!json.success) throw new Error(json.error||'Failed to update cart')
268
+ // Reload cart data (which will dispatch cart:updated event)
269
+ await loadCartData()
270
+ } catch (e) {
271
+ console.error('Failed to update quantity:', e)
272
+ // Revert input value
273
+ input.value = input.defaultValue || '1'
274
+ }
275
+ }
276
+
277
+ async function removeItem(btn){
278
+ const productId = btn.dataset.productId
279
+ const variantId = btn.dataset.variantId
280
+ try {
281
+ const res = await fetch('/webstoreapi/cart/remove', {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify({ productId, variantId })
285
+ })
286
+ const json = await res.json()
287
+ if(!json.success) throw new Error(json.error||'Failed to remove item')
288
+ // Reload cart data (which will dispatch cart:updated event)
289
+ await loadCartData()
290
+ } catch (e) {
291
+ console.error('Failed to remove item:', e)
292
+ }
293
+ }
294
+
295
+ // Flag to prevent multiple simultaneous API calls
296
+ // Use CartManager instead of making direct API calls
297
+ // This prevents duplicate API calls since CartManager handles caching and deduplication
298
+ function initCartQuantity() {
299
+ // Wait for CartManager to be available
300
+ const checkCartManager = () => {
301
+ if (window.CartManager && typeof window.CartManager.getCartCount === 'function') {
302
+ // Get cart count from CartManager (uses cached value if available)
303
+ window.CartManager.getCartCount().then(quantity => {
304
+ updateCartCount(quantity)
305
+ }).catch(() => {
306
+ // Silently fail if CartManager fails
307
+ })
308
+ } else {
309
+ // Retry after a short delay if CartManager not ready
310
+ setTimeout(checkCartManager, 50)
311
+ }
312
+ }
313
+
314
+ // Listen for cart:updated events from CartManager
315
+ // IMPORTANT: Use skipDispatch=true to prevent infinite recursion
316
+ // CartManager.dispatchCartUpdated() already updates badges and dispatches events
317
+ // We only need to update the UI, not dispatch another event
318
+ document.addEventListener('cart:updated', (event) => {
319
+ const count = event.detail?.count || 0
320
+ // Skip dispatch to prevent recursion - CartManager already handled badge updates
321
+ updateCartCount(count, true)
322
+ })
323
+
324
+ // Wait a bit to ensure DOM and CartManager are ready
325
+ setTimeout(() => {
326
+ checkCartManager()
327
+ }, 150)
328
+ }
329
+
330
+ // Set up initialization - only once
331
+ let initCalled = false
332
+ const runCartQuantityInit = () => {
333
+ if (initCalled) return
334
+ initCalled = true
335
+ initCartQuantity()
336
+ }
337
+
338
+ // Run when DOM is ready
339
+ if (document.readyState === 'loading') {
340
+ document.addEventListener('DOMContentLoaded', runCartQuantityInit, { once: true })
341
+ } else {
342
+ runCartQuantityInit()
343
+ }
344
+
345
+ document.addEventListener('DOMContentLoaded', function(){
346
+ const toggle = qsa('[data-cart-toggle]')
347
+ toggle.forEach(t => t.addEventListener('click', function(e){
348
+ e.preventDefault()
349
+ e.stopPropagation()
350
+ openDrawer()
351
+ }))
352
+
353
+ document.body.addEventListener('click', function(e){
354
+ const closeBtn = e.target.closest('[data-cart-close]')
355
+ const overlay = e.target.closest('[data-cart-overlay]')
356
+ if (closeBtn || overlay) {
357
+ e.preventDefault()
358
+ closeDrawer()
359
+ }
360
+ })
361
+
362
+ document.body.addEventListener('click', function(e){
363
+ const dec = e.target.closest('.qty-btn[data-action="decrease"]')
364
+ const inc = e.target.closest('.qty-btn[data-action="increase"]')
365
+ const removeBtn = e.target.closest('[data-remove-item]')
366
+ if (dec) {
367
+ const input = dec.parentElement.querySelector('.qty-input')
368
+ if (input) updateQuantity(input, -1)
369
+ }
370
+ if (inc) {
371
+ const input = inc.parentElement.querySelector('.qty-input')
372
+ if (input) updateQuantity(input, +1)
373
+ }
374
+ if (removeBtn) {
375
+ e.preventDefault()
376
+ removeItem(removeBtn)
377
+ }
378
+ })
379
+
380
+ document.body.addEventListener('change', function(e){
381
+ const input = e.target.closest('.qty-input')
382
+ if (input) syncQuantity(input)
383
+ })
384
+
385
+ // Checkout button
386
+ const checkoutBtn = qs('#cart-drawer-checkout-btn')
387
+ if (checkoutBtn) {
388
+ // Store original button text in data attribute for restoration
389
+ if (!checkoutBtn.dataset.originalText) {
390
+ checkoutBtn.dataset.originalText = checkoutBtn.textContent.trim() || 'Check out'
391
+ }
392
+
393
+ checkoutBtn.addEventListener('click', function(e) {
394
+ // Prevent multiple clicks
395
+ if (checkoutBtn.classList.contains('loading') || checkoutBtn.disabled) {
396
+ e.preventDefault()
397
+ return
398
+ }
399
+
400
+ // Add loading state
401
+ checkoutBtn.classList.add('loading')
402
+ checkoutBtn.disabled = true
403
+ checkoutBtn.setAttribute('aria-disabled', 'true')
404
+ checkoutBtn.innerHTML = '<span class="loading-spinner"></span> Processing...'
405
+
406
+ // Set a timeout fallback to reset button if navigation doesn't happen
407
+ const resetTimeout = setTimeout(() => {
408
+ resetCheckoutButton()
409
+ }, 5000) // Reset after 5 seconds if navigation hasn't occurred
410
+
411
+ // Navigate to checkout
412
+ // Clear timeout if navigation succeeds (page will unload)
413
+ window.addEventListener('beforeunload', () => {
414
+ clearTimeout(resetTimeout)
415
+ })
416
+
417
+ try {
418
+ window.location.href = '/checkout'
419
+ } catch (error) {
420
+ // If navigation fails, reset button immediately
421
+ console.error('Navigation failed:', error)
422
+ clearTimeout(resetTimeout)
423
+ resetCheckoutButton()
424
+ }
425
+ })
426
+ }
427
+
428
+ // Discount toggle
429
+ const discountToggle = qs('[data-discount-toggle]')
430
+ if (discountToggle) {
431
+ discountToggle.addEventListener('click', function() {
432
+ const content = qs('[data-discount-content]')
433
+ const isExpanded = discountToggle.getAttribute('aria-expanded') === 'true'
434
+ discountToggle.setAttribute('aria-expanded', !isExpanded)
435
+ if (content) {
436
+ content.style.display = isExpanded ? 'none' : 'block'
437
+ }
438
+ })
439
+ }
440
+
441
+ // Close on Escape key
442
+ document.addEventListener('keydown', function(e) {
443
+ if (e.key === 'Escape') {
444
+ const drawer = qs('#cart-drawer')
445
+ if (drawer && drawer.classList.contains('active')) {
446
+ closeDrawer()
447
+ }
448
+ }
449
+ })
450
+
451
+ // Auto-open cart drawer if ?openCart=true is in URL (e.g., from /cart redirect)
452
+ const urlParams = new URLSearchParams(window.location.search)
453
+ if (urlParams.get('openCart') === 'true') {
454
+ // Remove the query parameter from URL without reloading
455
+ const url = new URL(window.location.href)
456
+ url.searchParams.delete('openCart')
457
+ window.history.replaceState({}, '', url)
458
+
459
+ // Open the drawer
460
+ openDrawer()
461
+ }
462
+ })
463
+ })()
@@ -0,0 +1,223 @@
1
+ /**
2
+ * O2VEND Cart Manager
3
+ * Centralized cart state management and event system
4
+ * Ensures cart badge updates across all components
5
+ */
6
+
7
+ (function() {
8
+ 'use strict';
9
+
10
+ const CartManager = {
11
+ // Cache for cart quantity to prevent duplicate API calls
12
+ _cartQuantityCache: null,
13
+ _cartQuantityLoading: false,
14
+ _cartQuantityPromise: null,
15
+ _cacheTimestamp: null,
16
+ _cacheTTL: 5000, // Cache for 5 seconds
17
+
18
+ /**
19
+ * Update cart badge count across all elements with [data-cart-count]
20
+ * @param {number} count - Cart item count
21
+ */
22
+ updateCartBadge(count) {
23
+ const numericCount = parseInt(count, 10) || 0;
24
+
25
+ // Update all cart badges using [data-cart-count] - single source of truth
26
+ // This includes both header badges and cart drawer title badges
27
+ const countElements = document.querySelectorAll('[data-cart-count]');
28
+ countElements.forEach(element => {
29
+ // Update both text content and data attribute
30
+ element.textContent = numericCount;
31
+ element.setAttribute('data-cart-count', numericCount.toString());
32
+
33
+ // Show/hide badge based on count
34
+ // CSS rule [data-cart-count="0"] hides it automatically when data attribute is "0"
35
+ // For drawer title, we always show the count (even if 0), so check parent context
36
+ const isDrawerTitle = element.closest('.cart-drawer-title');
37
+
38
+ if (numericCount > 0) {
39
+ // Remove any inline display style to let CSS handle it
40
+ // CSS has display: inline-flex by default, and won't hide when data-cart-count != "0"
41
+ element.removeAttribute('style');
42
+ } else {
43
+ // Hide header badges when count is 0, but keep drawer title visible
44
+ if (!isDrawerTitle) {
45
+ element.style.display = 'none';
46
+ } else {
47
+ // Drawer title should always be visible, just show "0"
48
+ element.removeAttribute('style');
49
+ }
50
+ }
51
+ });
52
+
53
+ // Update aria-label on cart toggle buttons
54
+ const cartToggles = document.querySelectorAll('[data-cart-toggle]');
55
+ cartToggles.forEach(toggle => {
56
+ toggle.setAttribute('aria-label', `Shopping cart with ${numericCount} item${numericCount !== 1 ? 's' : ''}`);
57
+ });
58
+
59
+ // Debug logging (can be removed in production)
60
+ console.log('[CartManager] Updated cart badge:', numericCount, 'badges:', countElements.length);
61
+ },
62
+
63
+ /**
64
+ * Fetch current cart count from API with caching and deduplication
65
+ * Multiple simultaneous calls will share the same promise
66
+ * @param {boolean} forceRefresh - Force refresh cache (default: false)
67
+ * @returns {Promise<number>} Cart item count
68
+ */
69
+ async getCartCount(forceRefresh = false) {
70
+ // Return cached value if available and not expired
71
+ const now = Date.now();
72
+ if (!forceRefresh && this._cartQuantityCache !== null && this._cacheTimestamp) {
73
+ const cacheAge = now - this._cacheTimestamp;
74
+ if (cacheAge < this._cacheTTL) {
75
+ console.log('[CartManager] Returning cached cart count:', this._cartQuantityCache);
76
+ return this._cartQuantityCache;
77
+ }
78
+ }
79
+
80
+ // If already loading, return the existing promise to prevent duplicate calls
81
+ if (this._cartQuantityLoading && this._cartQuantityPromise) {
82
+ console.log('[CartManager] Cart quantity already loading, returning existing promise');
83
+ return this._cartQuantityPromise;
84
+ }
85
+
86
+ // Create and cache the promise
87
+ this._cartQuantityLoading = true;
88
+ this._cartQuantityPromise = (async () => {
89
+ try {
90
+ const response = await fetch('/webstoreapi/carts/quantity', {
91
+ method: 'GET',
92
+ credentials: 'same-origin',
93
+ headers: {
94
+ 'Accept': 'application/json'
95
+ }
96
+ });
97
+
98
+ if (!response.ok) {
99
+ return 0;
100
+ }
101
+
102
+ const json = await response.json();
103
+ if (json.success && json.data && json.data.cartQuantity !== undefined) {
104
+ const count = parseInt(json.data.cartQuantity, 10) || 0;
105
+ // Update cache
106
+ this._cartQuantityCache = count;
107
+ this._cacheTimestamp = Date.now();
108
+ console.log('[CartManager] Fetched and cached cart count:', count);
109
+ return count;
110
+ }
111
+
112
+ return 0;
113
+ } catch (error) {
114
+ console.error('[CartManager] Failed to fetch cart count:', error);
115
+ return 0;
116
+ } finally {
117
+ this._cartQuantityLoading = false;
118
+ this._cartQuantityPromise = null;
119
+ }
120
+ })();
121
+
122
+ return this._cartQuantityPromise;
123
+ },
124
+
125
+ /**
126
+ * Dispatch cart:updated event with cart data
127
+ * @param {Object} cartData - Cart data object with itemCount
128
+ * @param {number} cartData.itemCount - Number of items in cart
129
+ * @param {Object} cartData.cart - Full cart object (optional)
130
+ */
131
+ dispatchCartUpdated(cartData) {
132
+ // Extract count from various possible locations in the response
133
+ let count = 0;
134
+ if (cartData) {
135
+ count = cartData.itemCount ||
136
+ cartData.cartQuantity ||
137
+ (cartData.items && cartData.items.length) ||
138
+ (cartData.cart && (cartData.cart.itemCount || cartData.cart.cartQuantity || (cartData.cart.items && cartData.cart.items.length))) ||
139
+ 0;
140
+ }
141
+
142
+ console.log('[CartManager] Dispatching cart:updated event with count:', count, 'cartData:', cartData);
143
+
144
+ const event = new CustomEvent('cart:updated', {
145
+ detail: {
146
+ count: count,
147
+ cart: cartData.cart || cartData
148
+ },
149
+ bubbles: true,
150
+ cancelable: true
151
+ });
152
+
153
+ document.dispatchEvent(event);
154
+
155
+ // Update badge immediately
156
+ this.updateCartBadge(count);
157
+
158
+ // Invalidate cache when cart is updated
159
+ this._cartQuantityCache = count;
160
+ this._cacheTimestamp = Date.now();
161
+ },
162
+
163
+ /**
164
+ * Invalidate cart quantity cache (call when cart changes)
165
+ */
166
+ invalidateCache() {
167
+ this._cartQuantityCache = null;
168
+ this._cacheTimestamp = null;
169
+ console.log('[CartManager] Cart quantity cache invalidated');
170
+ },
171
+
172
+ /**
173
+ * Initialize cart manager and set up event listeners
174
+ */
175
+ init() {
176
+ console.log('[CartManager] Initializing...');
177
+
178
+ // Listen for cart:updated events from external sources
179
+ // Note: dispatchCartUpdated() already calls updateCartBadge() directly,
180
+ // so this listener handles events from other components (like cart-drawer fallback)
181
+ // It's safe to call updateCartBadge() multiple times as it's idempotent
182
+ document.addEventListener('cart:updated', (event) => {
183
+ const count = event.detail.count || 0;
184
+ console.log('[CartManager] Received cart:updated event with count:', count);
185
+ // updateCartBadge is idempotent, so calling it multiple times is safe
186
+ this.updateCartBadge(count);
187
+ });
188
+
189
+ // Load initial cart count on page load
190
+ const initCart = () => {
191
+ console.log('[CartManager] DOM ready, loading initial cart count...');
192
+ this.loadInitialCartCount();
193
+ };
194
+
195
+ if (document.readyState === 'loading') {
196
+ document.addEventListener('DOMContentLoaded', initCart);
197
+ } else {
198
+ // DOM already ready, but wait a bit to ensure all elements are rendered
199
+ setTimeout(initCart, 50);
200
+ }
201
+ },
202
+
203
+ /**
204
+ * Load initial cart count on page load
205
+ */
206
+ async loadInitialCartCount() {
207
+ // Small delay to ensure DOM is ready
208
+ setTimeout(async () => {
209
+ const count = await this.getCartCount();
210
+ console.log('[CartManager] Loaded initial cart count:', count);
211
+ this.updateCartBadge(count);
212
+ }, 100);
213
+ }
214
+ };
215
+
216
+ // Initialize cart manager
217
+ CartManager.init();
218
+
219
+ // Make CartManager available globally
220
+ window.CartManager = CartManager;
221
+
222
+ })();
223
+