@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.
- package/README.md +425 -0
- package/assets/Logo_o2vend.png +0 -0
- package/assets/favicon.png +0 -0
- package/assets/logo-white.png +0 -0
- package/bin/o2vend +42 -0
- package/config/widget-map.json +50 -0
- package/lib/commands/check.js +201 -0
- package/lib/commands/generate.js +33 -0
- package/lib/commands/init.js +214 -0
- package/lib/commands/optimize.js +216 -0
- package/lib/commands/package.js +208 -0
- package/lib/commands/serve.js +105 -0
- package/lib/commands/validate.js +191 -0
- package/lib/lib/api-client.js +357 -0
- package/lib/lib/dev-server.js +2618 -0
- package/lib/lib/file-watcher.js +80 -0
- package/lib/lib/hot-reload.js +106 -0
- package/lib/lib/liquid-engine.js +822 -0
- package/lib/lib/liquid-filters.js +671 -0
- package/lib/lib/mock-api-server.js +989 -0
- package/lib/lib/mock-data.js +1468 -0
- package/lib/lib/widget-service.js +321 -0
- package/package.json +70 -0
- package/test-theme/README.md +27 -0
- package/test-theme/assets/async-sections.js +446 -0
- package/test-theme/assets/cart-drawer.js +463 -0
- package/test-theme/assets/cart-manager.js +223 -0
- package/test-theme/assets/checkout-price-handler.js +368 -0
- package/test-theme/assets/components.css +4629 -0
- package/test-theme/assets/delivery-zone.css +299 -0
- package/test-theme/assets/delivery-zone.js +396 -0
- package/test-theme/assets/logo.png +0 -0
- package/test-theme/assets/sections.css +48 -0
- package/test-theme/assets/theme.css +3500 -0
- package/test-theme/assets/theme.js +3745 -0
- package/test-theme/config/settings_data.json +292 -0
- package/test-theme/config/settings_schema.json +1050 -0
- package/test-theme/layout/theme.liquid +195 -0
- package/test-theme/locales/en.default.json +260 -0
- package/test-theme/sections/content-fallback.liquid +53 -0
- package/test-theme/sections/content.liquid +57 -0
- package/test-theme/sections/footer-fallback.liquid +328 -0
- package/test-theme/sections/footer.liquid +278 -0
- package/test-theme/sections/header-fallback.liquid +1805 -0
- package/test-theme/sections/header.liquid +1145 -0
- package/test-theme/sections/hero-fallback.liquid +212 -0
- package/test-theme/sections/hero.liquid +136 -0
- package/test-theme/snippets/account-sidebar.liquid +200 -0
- package/test-theme/snippets/add-to-cart-modal.liquid +484 -0
- package/test-theme/snippets/breadcrumbs.liquid +134 -0
- package/test-theme/snippets/cart-drawer.liquid +467 -0
- package/test-theme/snippets/delivery-zone-city-selector.liquid +79 -0
- package/test-theme/snippets/delivery-zone-modal.liquid +337 -0
- package/test-theme/snippets/delivery-zone-search.liquid +78 -0
- package/test-theme/snippets/icon.liquid +105 -0
- package/test-theme/snippets/login-modal.liquid +346 -0
- package/test-theme/snippets/mega-menu.liquid +812 -0
- package/test-theme/snippets/news-thumbnail.liquid +187 -0
- package/test-theme/snippets/pagination.liquid +120 -0
- package/test-theme/snippets/price.liquid +92 -0
- package/test-theme/snippets/product-card-related.liquid +78 -0
- package/test-theme/snippets/product-card-simple.liquid +41 -0
- package/test-theme/snippets/product-card.liquid +697 -0
- package/test-theme/snippets/rating.liquid +85 -0
- package/test-theme/snippets/skeleton-collection-grid.liquid +114 -0
- package/test-theme/snippets/skeleton-product-card.liquid +124 -0
- package/test-theme/snippets/skeleton-product-grid.liquid +34 -0
- package/test-theme/snippets/social-sharing.liquid +185 -0
- package/test-theme/templates/account/dashboard.liquid +401 -0
- package/test-theme/templates/account/loyalty-redemption.liquid +405 -0
- package/test-theme/templates/account/loyalty.liquid +588 -0
- package/test-theme/templates/account/order-detail.liquid +230 -0
- package/test-theme/templates/account/orders.liquid +349 -0
- package/test-theme/templates/account/profile.liquid +758 -0
- package/test-theme/templates/account/register.liquid +232 -0
- package/test-theme/templates/account/return-orders.liquid +348 -0
- package/test-theme/templates/account/store-credit.liquid +464 -0
- package/test-theme/templates/account/subscriptions.liquid +601 -0
- package/test-theme/templates/account/wishlist.liquid +419 -0
- package/test-theme/templates/address-book.liquid +1092 -0
- package/test-theme/templates/categories.liquid +452 -0
- package/test-theme/templates/checkout.liquid +4511 -0
- package/test-theme/templates/error.liquid +384 -0
- package/test-theme/templates/index.liquid +11 -0
- package/test-theme/templates/login.liquid +185 -0
- package/test-theme/templates/order-confirmation.liquid +720 -0
- package/test-theme/templates/page.liquid +297 -0
- package/test-theme/templates/product-detail.liquid +4363 -0
- package/test-theme/templates/products.liquid +518 -0
- package/test-theme/templates/search.liquid +922 -0
- package/test-theme/theme.json.example +19 -0
- package/test-theme/widgets/brand-carousel.liquid +676 -0
- package/test-theme/widgets/brand.liquid +245 -0
- package/test-theme/widgets/carousel.liquid +843 -0
- package/test-theme/widgets/category-list-carousel.liquid +656 -0
- package/test-theme/widgets/category-list.liquid +340 -0
- package/test-theme/widgets/category.liquid +475 -0
- package/test-theme/widgets/discount-time.liquid +176 -0
- package/test-theme/widgets/footer-menu.liquid +695 -0
- package/test-theme/widgets/footer.liquid +179 -0
- package/test-theme/widgets/gallery.liquid +271 -0
- package/test-theme/widgets/header-menu.liquid +932 -0
- package/test-theme/widgets/header.liquid +159 -0
- package/test-theme/widgets/html.liquid +214 -0
- package/test-theme/widgets/news.liquid +217 -0
- package/test-theme/widgets/product-canvas.liquid +235 -0
- package/test-theme/widgets/product-carousel.liquid +502 -0
- package/test-theme/widgets/product.liquid +45 -0
- package/test-theme/widgets/recently-viewed.liquid +26 -0
- package/test-theme/widgets/shared/product-grid.liquid +339 -0
- package/test-theme/widgets/simple-product.liquid +42 -0
- package/test-theme/widgets/single-product.liquid +610 -0
- package/test-theme/widgets/spacebar-carousel.liquid +663 -0
- package/test-theme/widgets/spacebar.liquid +279 -0
- package/test-theme/widgets/splash.liquid +378 -0
- 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
|
+
|