/* * Attribute Cart - v3.7.7 (rolled back) * + product-slug + card deferred add * + open-on-add + reopen-on-click * + cart-ignores-unavailable (live) * + cart-empty only when truly empty * + MULTI-CART: поддержка нескольких [cart-data] на одной странице * + JSON экспорт ИЗ product-info="*" * + [cart-total-price] (совместимо с [cart-delivery-total]) * + Консольная команда: cart-check() * + [go-to-checkout] блокируется при недоступных товарах + window.GO_TO_CHECKOUT_STATUS * + delivery-price: из [cart-data] [cart-delivery] (только атрибут). "free" | число * + NEW: [product-unit] внутри каждого [product-count] показывает "кг" (step=0.1) или "шт" (step=1) * и эти данные попадают в JSON: item.unit ("кг"/"шт"), item["unit-step"] (0.1|1) */ (function () { const LS_KEY_CART = 'attrCart:v3.7'; const LS_KEY_UI = 'attrCart:v3.7:cardState'; const LS_KEY_PENDING_SLUG = 'attrCart:v3.7:pendingSlug'; const LS_KEY_LOCATION = 'attrCart:location'; const AVAIL_POLL_MS = 1200; // ===== Formatting (no currency sign) ===== const LOCALE = document.documentElement.lang || 'uk-UA'; const DECIMALS = 2; const money = (n) => { const opts = { minimumFractionDigits: 0, maximumFractionDigits: DECIMALS }; return new Intl.NumberFormat(LOCALE, opts).format(Number(n) || 0); }; // ===== Utils ===== const num = (v, def=0) => { if (v == null) return def; const n = parseFloat(String(v).replace(/[^\d.,-]/g, '').replace(',', '.')); return isNaN(n) ? def : n; }; const roundTo = (x, step) => Math.round(x / step) * step; const readInfoValue = (el) => { if (!el) return ''; const tag = el.tagName?.toLowerCase?.() || ''; if (tag === 'input' || tag === 'textarea' || tag === 'select') return el.value?.trim?.() ?? ''; if (tag === 'img') return el.getAttribute('src') || el.src || ''; return (el.textContent || '').trim(); }; function djb2(str) { let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h) + str.charCodeAt(i); return (h >>> 0).toString(36); } const safeStringify = (obj) => { try { return JSON.stringify(obj) } catch(e) { return String(obj) } }; // ===== Units ===== function unitLabelByStep(step) { return Number(step) === 1 ? 'шт' : 'кг'; } // Text node inside [add-to-cart] function getAddToCartLabelNode(btn) { let node = btn.querySelector('[add-to-cart-text], [add-to-cart-label]'); if (node) return node; const walker = document.createTreeWalker(btn, NodeFilter.SHOW_ELEMENT, { acceptNode(n) { if (n === btn) return NodeFilter.FILTER_SKIP; if (n.children && n.children.length > 0) return NodeFilter.FILTER_SKIP; const t = (n.textContent || '').trim(); return t ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } }); node = walker.nextNode(); return node || btn; } // Quantize by step/min function clampQty(q, step, min) { const s = Math.max(step || 0.1, 0.1); const m = Math.max(min || s, s); let rounded = roundTo(q, s); if (rounded <= 0) return 0; if (rounded < m) rounded = m; return s === 0.1 ? Math.round(rounded * 10) / 10 : Math.round(rounded); } // ===== Product state (per [product-data]) ===== const PRODUCT_STATE = new WeakMap(); // ===== UI-state (selected options per card) ===== function loadCardState() { try { return JSON.parse(localStorage.getItem(LS_KEY_UI)) || {}; } catch { return {}; } } function saveCardState(stateObj) { localStorage.setItem(LS_KEY_UI, JSON.stringify(stateObj || {})); } // ===== ID helpers ===== function readProductSlug(root) { const el = root.querySelector('[product-slug]'); const val = readInfoValue(el); return String(val || '').trim(); } function getBoxKey(box) { const data = {}; box.querySelectorAll('[product-info]').forEach((n) => { const key = n.getAttribute('product-info'); if (key) data[key] = readInfoValue(n); }); let pid = readProductSlug(box); if (!pid) { pid = (data.slug || '').trim(); if (!pid) pid = (data.id || data.sku || '').trim(); if (!pid) { const link = box.querySelector('a[href*="/products/"], a[href*="/product/"]'); const href = link?.getAttribute('href'); if (href) { const seg = href.split('/').filter(Boolean).pop(); if (seg) pid = seg.trim(); } } if (!pid) { const basis = `${data.name || ''}|${(data.variant || '').trim()}`.trim(); if (basis) pid = 'hx_' + djb2(basis); } } const variant = String((data.variant || '').trim()); return `${String(pid || '').trim()}__${variant}`; } function computeStepMin(box) { const limiter = box.querySelector('[product-limit]'); const perOne = !!(limiter && limiter.classList.contains('w-condition-invisible')); const step = perOne ? 1 : 0.1; const min = perOne ? 1 : 0.1; return { step, min }; } function initProductCount(box) { if (!box) return; const { step, min } = computeStepMin(box); const prev = PRODUCT_STATE.get(box); const state = { step, min, qty: clampQty(step, step, min), options: prev?.options || new Map() }; PRODUCT_STATE.set(box, state); // set inputs box.querySelectorAll('[product-count-input]').forEach(input => { input.type = input.type || 'number'; input.step = String(step); input.min = String(min); input.value = String(state.qty); }); // set unit label on each product-count box.querySelectorAll('[product-count]').forEach(wrap => { const unitEl = wrap.querySelector('[product-unit]'); if (unitEl) unitEl.textContent = unitLabelByStep(step); }); // bind +/- and input events box.querySelectorAll('[product-count]').forEach(wrap => { const less = wrap.querySelector('[product-count-less]'); const more = wrap.querySelector('[product-count-more]'); const input = wrap.querySelector('[product-count-input]'); if (input) { input.addEventListener('change', () => { const st = PRODUCT_STATE.get(box); const v = num(input.value, st.step); st.qty = clampQty(v > 0 ? v : st.step, st.step, st.min); box.querySelectorAll('[product-count-input]').forEach(i => i.value = String(st.qty)); }); input.addEventListener('input', () => { const st = PRODUCT_STATE.get(box); const v = num(input.value, st.step); input.value = String(Math.max(st.step, v)); }); } if (less) less.addEventListener('click', () => bumpProductQty(box, -1)); if (more) more.addEventListener('click', () => bumpProductQty(box, +1)); }); } function bumpProductQty(box, dir) { const st = PRODUCT_STATE.get(box); if (!st) return; st.qty = clampQty(st.qty + (dir >= 0 ? +1 : -1) * st.step, st.step, st.min); box.querySelectorAll('[product-count-input]').forEach(i => i.value = String(st.qty)); } // ==== Product options (card) ==== function readOptionNameStrict(optEl) { const nameEl = optEl.querySelector('[product-option-name]'); return nameEl ? (nameEl.textContent || '').trim() : ''; } function readOptionPrice(optEl) { return num(optEl.getAttribute('product-option'), 0); } function applyOptionVisual(optEl, selected) { optEl.classList.remove('is-selected', 'option-selected'); const icon = optEl.querySelector('[product-option-icon]'); if (icon) icon.classList.remove('option-selected'); if (selected) { optEl.classList.add('is-selected', 'option-selected'); if (icon) icon.classList.add('option-selected'); } } function toggleProductOption(box, optEl) { const st = PRODUCT_STATE.get(box); if (!st) return; const name = readOptionNameStrict(optEl); if (!name) return; const price = readOptionPrice(optEl); const key = getBoxKey(box); const uiState = loadCardState(); const set = new Set(uiState[key] || []); if (st.options.has(name)) { st.options.delete(name); set.delete(name); applyOptionVisual(optEl, false); } else { st.options.set(name, price); set.add(name); applyOptionVisual(optEl, true); } uiState[key] = Array.from(set); saveCardState(uiState); const data = collectProductFrom(box); const existing = Cart.items.find(i => keyOf(i) === keyOf(data)); if (existing) { existing.options = Array.from(st.options.entries()).map(([n, p]) => ({ name: n, price: Number(p) || 0 })); Cart.save(); renderAll(); } } function initProductOptions(box) { const key = getBoxKey(box); const cartItem = Cart.items.find(i => `${i.id}__${i.variant || ''}` === key); const namesFromCart = new Set( cartItem && Array.isArray(cartItem.options) ? cartItem.options.map(o => String(o.name || '')) : [] ); const uiState = loadCardState(); const namesFromUI = new Set(Array.isArray(uiState[key]) ? uiState[key] : []); const stPrev = PRODUCT_STATE.get(box); const { step, min } = computeStepMin(box); const st = stPrev || { step, min, qty: step, options: new Map() }; const selectedNames = namesFromCart.size ? namesFromCart : namesFromUI; box.querySelectorAll('[product-option]').forEach(opt => applyOptionVisual(opt, false)); box.querySelectorAll('[product-option]').forEach(opt => { const name = readOptionNameStrict(opt); const price = readOptionPrice(opt); if (!name) return; const selected = selectedNames.has(name); if (selected) { st.options.set(name, price); applyOptionVisual(opt, true); } else { st.options.delete(name); } }); PRODUCT_STATE.set(box, st); box.querySelectorAll('[product-option]').forEach(opt => { opt.addEventListener('click', () => toggleProductOption(box, opt)); }); } // ===== Collect product from [product-data] ===== const collectProductFrom = (root) => { const data = {}; const info = {}; root.querySelectorAll('[product-info]').forEach((n) => { const key = n.getAttribute('product-info'); if (!key) return; const val = readInfoValue(n); data[key] = val; info[key] = val; // сохраняем для JSON }); data.price = num(data.price, 0); const { step, min } = computeStepMin(root); data.step = step; data.min = min; const st = PRODUCT_STATE.get(root); data.qty = st ? st.qty : step; data.options = (st && st.options && st.options.size) ? Array.from(st.options.entries()).map(([n,p]) => ({ name: n, price: Number(p) || 0 })) : []; const slugEl = root.querySelector('[product-slug]'); const slug = readInfoValue(slugEl) || readProductSlug(root); data.id = String(slug || '').trim(); if (!data.id) { data.variant = String(data.variant || '').trim(); let pid = (data.slug || '').trim(); if (!pid) pid = (data.id || data.sku || '').trim(); if (!pid) { const link = root.querySelector('a[href*="/products/"], a[href*="/product/"]'); const href = link?.getAttribute('href'); if (href) { const seg = href.split('/').filter(Boolean).pop(); if (seg) pid = seg.trim(); } } if (!pid) { const basis = `${data.name || ''}|${data.variant || ''}`.trim(); if (basis) pid = 'hx_' + djb2(basis); } data.id = String(pid || '').trim(); } data.variant = String(data.variant || '').trim(); data.__info = info; return data; }; const keyOf = (it) => `${it.id}__${it.variant || ''}`; // ===== Pricing ===== function lineTotal(it) { const unit = Number(it.price) || 0; const step = Number(it.step) || 0.1; const qty = Number(it.qty) || step; const base = unit * (qty / step); const factorPieces = (step === 1) ? qty : 1; const opts = Array.isArray(it.options) ? it.options : []; const optsSum = opts.reduce((s, o) => s + (Number(o?.price) || 0), 0) * factorPieces; return base + optsSum; } // ===== Availability (PRODUCT_AVIALABILITY_FOR_CART) ===== function getAvailabilitySource() { return window.PRODUCT_AVIALABILITY_FOR_CART || window.RODUCT_AVIALABILITY_FOR_CART; } function isProductAvailableByBox(box) { try { const src = getAvailabilitySource(); const city = (box.getAttribute('product-avialability-city') || '').trim(); const street = (box.getAttribute('product-avialability-street') || '').trim(); if (typeof src?.isAvailableByAttrs === 'function') { return !!src.isAvailableByAttrs(city, street); } const key = `${city}||${street}`; if (src?.map && (key in src.map)) { return !!src.map[key]; } return true; } catch { return true; } } function readAnyCityStreetForCart() { const carts = Array.from(document.querySelectorAll('[cart-data]')); for (const c of carts) { const city = (c.getAttribute('product-avialability-city') || '').trim(); const street = (c.getAttribute('product-avialability-street') || '').trim(); if (city || street) return {city, street}; } try { const saved = JSON.parse(localStorage.getItem(LS_KEY_LOCATION) || '{}'); if (saved && (saved.city || saved.street)) return {city: saved.city||'', street: saved.street||''}; } catch {} const anyBox = document.querySelector('[product-data]'); if (anyBox) { return { city: (anyBox.getAttribute('product-avialability-city') || '').trim(), street: (anyBox.getAttribute('product-avialability-street') || '').trim() }; } return {city: '', street: ''}; } function findBoxBySlug(slug) { const nodes = document.querySelectorAll('[product-data]'); for (const b of nodes) { const s = readProductSlug(b); if (s && s === slug) return b; } return null; } function isItemAvailable(it) { const box = findBoxBySlug(it.id); if (box) return isProductAvailableByBox(box); try { const src = getAvailabilitySource(); const {city, street} = readAnyCityStreetForCart(); if (!city && !street && typeof src?.isAvailableByAttrs === 'function') { return true; } if (typeof src?.isAvailableByAttrs === 'function') { return !!src.isAvailableByAttrs(city, street); } const key = `${city}||${street}`; if (src?.map && (key in src.map)) { return !!src.map[key]; } } catch {} return true; } function applyAvailabilityToBox(box) { const available = isProductAvailableByBox(box); const isCard = (box.getAttribute('product-data') || '').trim().toLowerCase() === 'card'; box.dataset.productAvailable = available ? 'true' : 'false'; box.querySelectorAll('[add-to-cart]').forEach((btn) => { const labelNode = getAddToCartLabelNode(btn); if (!btn.dataset.addLabel) { btn.dataset.addLabel = (labelNode.textContent || '').trim(); } const notAvailLabel = btn.getAttribute('item-not-available-name') || 'Недоступно'; if (!available) { btn.classList.add('add-to-cart-disable'); btn.setAttribute('aria-disabled', 'true'); if (!isCard && btn.dataset.added !== 'true') { labelNode.textContent = notAvailLabel; } } else { btn.classList.remove('add-to-cart-disable'); if (btn.dataset.added !== 'true') { btn.removeAttribute('aria-disabled'); if (!isCard) labelNode.textContent = btn.dataset.addLabel || ''; } } }); } function refreshAvailabilityForAll() { document.querySelectorAll('[product-data]').forEach(applyAvailabilityToBox); } // ===== Delivery helpers ===== function getCartDeliveryBase(cartRoot) { try { const el = cartRoot ? cartRoot.querySelector('[cart-delivery]') : null; if (el) { const attrVal = el.getAttribute('cart-delivery'); if (attrVal != null && attrVal !== '') return num(attrVal, 0); } } catch {} // fallback: global from [product-data][product-delivery] const source = document.querySelector('[product-data][product-delivery]'); return source ? num(source.getAttribute('product-delivery'), 0) : 0; } // ===== Store ===== const Cart = { items: [], deliveryBase: null, load() { try { this.items = JSON.parse(localStorage.getItem(LS_KEY_CART)) || []; } catch { this.items = []; } this.items = this.items.map(i => { const step = i.step ? Number(i.step) : 0.1; const min = i.min ? Number(i.min) : step; const options = Array.isArray(i.options) ? i.options.map(o => ({ name: String(o?.name || ''), price: Number(o?.price) || 0 })) : []; const id = String(i.id || ''); const info = (i.__info && typeof i.__info === 'object') ? i.__info : {}; return { ...i, id, price: num(i.price, 0), step, min, qty: clampQty(num(i.qty, step), step, min), options, __info: info }; }); }, save() { localStorage.setItem(LS_KEY_CART, JSON.stringify(this.items)); }, add(product) { if (!product.id) { console.warn('⛔ [product-slug] пуст, позиция не добавлена'); return; } const k = keyOf(product); const exist = this.items.find(i => keyOf(i) === k); if (exist) { // не увеличиваем qty — регулировка в корзине } else { const initial = clampQty(product.qty ?? product.step, product.step, product.min); if (initial === 0) return; this.items.push({ id: String(product.id), name: product.name || '', price: num(product.price, 0), image: product.image || '', variant: product.variant || '', step: product.step, min: product.min, qty: initial, options: Array.isArray(product.options) ? product.options.slice() : [], __info: (product.__info && typeof product.__info === 'object') ? product.__info : {} }); } this.save(); renderAll(); }, setQty(id, variant, value) { const it = this.items.find(i => keyOf(i) === `${id}__${variant || ''}`); if (!it) return; const q = clampQty(num(value, it.step), it.step, it.min); if (q === 0) this.remove(id, variant); else { it.qty = q; this.save(); renderAll(); } }, inc(id, variant, dir) { const it = this.items.find(i => keyOf(i) === `${id}__${variant || ''}`); if (!it) return; const delta = (dir >= 0 ? +1 : -1) * it.step; const next = clampQty(it.qty + delta, it.step, it.min); if (next === 0) this.remove(id, variant); else { it.qty = next; this.save(); renderAll(); } }, remove(id, variant) { const k = `${id}__${variant || ''}`; this.items = this.items.filter(i => keyOf(i) !== k); this.save(); renderAll(); }, clear() { this.items = []; this.save(); renderAll(); }, removeOption(id, variant, label) { const it = this.items.find(i => keyOf(i) === `${id}__${variant || ''}`); if (!it || !Array.isArray(it.options)) return; it.options = it.options.filter(x => String(x.name) !== String(label)); this.save(); renderAll(); document.querySelectorAll('[product-data]').forEach(box => { const key = getBoxKey(box); if (key === `${id}__${variant || ''}`) { const ui = loadCardState(); ui[key] = (ui[key] || []).filter(n => String(n) !== String(label)); saveCardState(ui); box.querySelectorAll('[product-option]').forEach(opt => { const nm = readOptionNameStrict(opt); if (nm === label) applyOptionVisual(opt, false); }); const st = PRODUCT_STATE.get(box); if (st) { st.options.delete(label); PRODUCT_STATE.set(box, st); } } }); }, snapshot(cartRoot) { const availability = this.items.map(it => isItemAvailable(it)); const itemsAvailable = this.items.filter((_, idx) => availability[idx]); const uniqueCount = itemsAvailable.length; const subtotal = itemsAvailable.reduce((s, it) => s + lineTotal(it), 0); // --- delivery & free threshold (per cartRoot) --- const deliveryBase = getCartDeliveryBase(cartRoot); // attribute only const freeThresholdEl = cartRoot ? cartRoot.querySelector('[cart-free-delivery]') : null; let freeThreshold = 0; if (freeThresholdEl) { const attrVal = freeThresholdEl.getAttribute('cart-free-delivery'); freeThreshold = attrVal ? num(attrVal, 0) : 0; // только атрибут } const needForFree = Math.max(0, freeThreshold - subtotal); const freeReached = (freeThreshold > 0 && subtotal >= freeThreshold); const delivery = freeReached ? 0 : deliveryBase; const total = subtotal + (delivery || 0); return { items: this.items.slice(), availability, uniqueCount, subtotal, delivery, total, freeThreshold, needForFree, freeReached }; } }; // ===== Cart open/close ===== function showCartArea() { document.querySelectorAll('[cart-area]').forEach(area => { area.style.display = ''; area.classList.add('opened'); area.setAttribute('aria-hidden', 'false'); }); } function hideCartArea() { document.querySelectorAll('[cart-area]').forEach(area => { area.style.display = 'none'; area.classList.remove('opened'); area.setAttribute('aria-hidden', 'true'); }); } function bindCartOpenClose() { document.querySelectorAll('[cart-open]').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); showCartArea(); }); }); document.querySelectorAll('[cart-close]').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); hideCartArea(); }); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideCartArea(); }); } // ===== Add-to-cart: текст/блокировка ===== function refreshAddToCartStates() { document.querySelectorAll('[product-data]').forEach(box => { const key = getBoxKey(box); const inCart = Cart.items.some(i => `${i.id}__${i.variant || ''}` === key); const available = isProductAvailableByBox(box); const isCard = (box.getAttribute('product-data') || '').trim().toLowerCase() === 'card'; box.querySelectorAll('[add-to-cart]').forEach(btn => { const labelNode = getAddToCartLabelNode(btn); if (!btn.dataset.addLabel) btn.dataset.addLabel = (labelNode.textContent || '').trim(); const addedLabel = btn.getAttribute('item-added-name') || btn.dataset.addLabel || 'Added'; const notAvailLabel = btn.getAttribute('item-not-available-name') || 'Недоступно'; if (!available) { btn.classList.add('add-to-cart-disable'); btn.setAttribute('aria-disabled', 'true'); btn.dataset.added = inCart ? 'true' : 'false'; if (!isCard) { labelNode.textContent = inCart ? addedLabel : notAvailLabel; } return; } btn.classList.remove('add-to-cart-disable'); if (inCart) { if (!isCard) labelNode.textContent = addedLabel; btn.classList.add('is-added'); btn.removeAttribute('aria-disabled'); // клик открывает корзину btn.dataset.added = 'true'; } else { if (!isCard) labelNode.textContent = btn.dataset.addLabel || ''; btn.classList.remove('is-added'); btn.removeAttribute('aria-disabled'); btn.dataset.added = 'false'; } }); }); } // ===== Rendering (cart) ===== function renderInto(cartRoot) { const snap = Cart.snapshot(cartRoot); const { items, availability, uniqueCount, subtotal, delivery, total, freeThreshold, needForFree, freeReached } = snap; document.querySelectorAll('[cart-number]').forEach(el => el.textContent = String(uniqueCount)); cartRoot.querySelectorAll('[cart-total]').forEach(el => el.textContent = money(subtotal)); cartRoot.querySelectorAll('[cart-total-price]').forEach(el => el.textContent = money(total)); cartRoot.querySelectorAll('[cart-delivery-total]').forEach(el => el.textContent = money(total)); cartRoot.querySelectorAll('[cart-free-delivery-calc]').forEach(el => el.textContent = money(needForFree)); cartRoot.querySelectorAll('[cart-delivery]').forEach(el => { el.style.display = freeReached ? 'none' : ''; }); cartRoot.querySelectorAll('[cart-delivery-free]').forEach(el => { el.style.display = freeReached ? '' : 'none'; }); const isTrulyEmpty = items.length === 0; cartRoot.querySelectorAll('[cart-empty]').forEach(el => { const mode = (el.getAttribute('cart-empty') || '').trim().toLowerCase(); if (mode === 'alert') el.style.display = isTrulyEmpty ? '' : 'none'; else if (mode === 'hide') el.style.display = isTrulyEmpty ? 'none' : ''; }); const list = cartRoot.querySelector('[cart-list]') || cartRoot; const template = list.querySelector('[cart-item]') || cartRoot.querySelector('[cart-item]'); if (!template) return; Array.from(list.querySelectorAll('[cart-item]')).forEach(n => { if (n !== template) n.remove(); }); template.style.display = 'none'; items.forEach((it, idx) => { const node = template.cloneNode(true); node.style.display = ''; node.setAttribute('data-id', it.id); node.setAttribute('data-variant', it.variant || ''); const isAvail = !!availability[idx]; node.dataset.itemAvailable = isAvail ? 'true' : 'false'; const noneEl = node.querySelector('[cart-item-avialability-none]'); if (noneEl) noneEl.style.display = isAvail ? 'none' : ''; const step = Number(it.step) || 0.1; const qty = Number(it.qty) || step; const unitPrice = Number(it.price) || 0; const base = unitPrice * (qty / step); const factorPieces = (step === 1) ? qty : 1; const optsSum = (Array.isArray(it.options) ? it.options : []) .reduce((s,o)=> s + (Number(o?.price) || 0), 0) * factorPieces; const line = base + optsSum; // fill product-info in cart node.querySelectorAll('[product-info]').forEach((el) => { const key = el.getAttribute('product-info'); if (!key) return; let val; if (key === 'price' || key === 'item-total') { val = money(line); } else if (key === 'unit-price') { val = money(unitPrice); } else if (key === 'qty') { const tag = el.tagName.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') el.value = String(it.qty); else el.textContent = String(it.qty); return; } else { val = it[key] ?? ''; if (el.tagName.toLowerCase() === 'img') { if (String(val)) el.setAttribute('src', String(val)); return; } if (el.tagName.toLowerCase() === 'input') { el.value = String(val); return; } } if (el.tagName.toLowerCase() === 'input') el.value = String(val); else el.textContent = String(val); }); // set product-unit label inside cart product-count node.querySelectorAll('[product-count]').forEach((wrap) => { const unitEl = wrap.querySelector('[product-unit]'); if (unitEl) unitEl.textContent = unitLabelByStep(step); }); const optTemplate = node.querySelector('[cart-product-option]'); if (optTemplate) { Array.from(node.querySelectorAll('[cart-product-option]')).forEach(n => { if (n !== optTemplate) n.remove(); }); optTemplate.style.display = 'none'; const opts = Array.isArray(it.options) ? it.options : []; opts.forEach(o => { const label = String(o?.name || ''); const price = Number(o?.price) || 0; if (!label) return; const clone = optTemplate.cloneNode(true); clone.style.display = ''; const nameEl = clone.querySelector('[cart-product-option-name]'); if (nameEl) nameEl.textContent = label; else clone.textContent = label; const priceEl = clone.querySelector('[cart-product-option-price]'); if (priceEl) { const pp = price * ((step === 1) ? qty : 1); priceEl.textContent = money(pp); } clone.querySelectorAll('[cart-clear-product-option]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); Cart.removeOption(it.id, it.variant || '', label); }); }); optTemplate.parentNode.insertBefore(clone, optTemplate.nextSibling); }); } node.querySelectorAll('[product-count]').forEach((wrap) => { const less = wrap.querySelector('[product-count-less]'); const more = wrap.querySelector('[product-count-more]'); const input = wrap.querySelector('[product-count-input]'); const id = it.id; const variant = it.variant || ''; const setDisabled = (flag) => { if (!input && !less && !more) return; if (input) { input.disabled = flag; input.setAttribute('aria-disabled', String(!!flag)); } if (less) { less.disabled = flag; less.setAttribute('aria-disabled', String(!!flag)); } if (more) { more.disabled = flag; more.setAttribute('aria-disabled', String(!!flag)); } if (flag) wrap.classList.add('is-disabled'); else wrap.classList.remove('is-disabled'); }; setDisabled(!isAvail); if (input) { input.type = input.type || 'number'; input.step = String(it.step); input.min = String(it.min); input.value = String(it.qty); input.addEventListener('change', () => { if (isItemAvailable(it)) Cart.setQty(id, variant, input.value); }); input.addEventListener('input', () => { const v = num(input.value, it.step); input.value = String(Math.max(it.step, v)); }); } if (less) less.addEventListener('click', () => { if (isItemAvailable(it)) Cart.inc(id, variant, -1); }); if (more) more.addEventListener('click', () => { if (isItemAvailable(it)) Cart.inc(id, variant, +1); }); }); node.querySelectorAll('[cart-delete-item], [cart-item-remove]').forEach(btn => { btn.addEventListener('click', () => Cart.remove(it.id, it.variant || '')); }); list.appendChild(node); }); } function buildCheckoutJSON() { try { const snap = Cart.snapshot(document.querySelector('[cart-data]') || null); const items = snap.items.map((it, idx) => { const step = Number(it.step) || 0.1; const qty = Number(it.qty) || step; const unitPrice = Number(it.price) || 0; const factorPieces = (step === 1) ? qty : 1; const opts = Array.isArray(it.options) ? it.options : []; const base = unitPrice * (qty / step); const line = base + opts.reduce((s,o)=> s + (Number(o?.price) || 0), 0) * factorPieces; const unitLbl = unitLabelByStep(step); return { id: it.id, name: it.name || '', variant: it.variant || '', qty: qty, "unit-price": unitPrice, "item-total": line, available: !!snap.availability[idx], unit: unitLbl, // "кг" | "шт" "unit-step": step, // 0.1 | 1 options: opts.map(o => ({ name: String(o?.name || ''), price: Number(o?.price) || 0 })), "product-info": (it.__info && typeof it.__info === 'object') ? { ...it.__info } : {} }; }); const deliveryPriceValue = snap.freeReached ? "free" : Number(snap.delivery || 0); const cartJSON = { items, "cart-total-price": snap.total, "delivery-price": deliveryPriceValue }; window.CHECKOUT_CART_JSON = cartJSON; try { window.CHECKOUT_CART_LIST = items; } catch(e) {} window.dispatchEvent(new CustomEvent('checkout:cart-json-updated', { detail: cartJSON })); } catch(e) {} } // ===== Go-to-checkout controls ===== function updateGoToCheckoutControls() { try { const snap = Cart.snapshot(document.querySelector('[cart-data]') || null); const allAvailable = snap.items.length > 0 && snap.availability.every(Boolean); window.GO_TO_CHECKOUT_STATUS = allAvailable; document.querySelectorAll('[go-to-checkout]').forEach((btn) => { if (allAvailable) { btn.classList.remove('go-to-checkout-disable'); btn.removeAttribute('aria-disabled'); } else { btn.classList.add('go-to-checkout-disable'); btn.setAttribute('aria-disabled', 'true'); } if (!btn.dataset.gotoBound) { btn.addEventListener('click', (e) => { const ok = !!window.GO_TO_CHECKOUT_STATUS; if (!ok) { e.preventDefault(); e.stopPropagation(); } }, true); btn.dataset.gotoBound = '1'; } }); window.dispatchEvent(new CustomEvent('checkout:go-to-status-updated', { detail: { status: allAvailable } })); } catch(e) {} } function renderAll() { const carts = Array.from(document.querySelectorAll('[cart-data]')); if (carts.length === 0) { refreshAddToCartStates(); refreshAvailabilityForAll(); } else { carts.forEach(renderInto); refreshAddToCartStates(); refreshAvailabilityForAll(); } buildCheckoutJSON(); updateGoToCheckoutControls(); } // ===== Deferred add (card mode) ===== function setPendingSlug(slug) { try { localStorage.setItem(LS_KEY_PENDING_SLUG, String(slug || '')); } catch(e) {} } function getPendingSlug() { try { return localStorage.getItem(LS_KEY_PENDING_SLUG) || ''; } catch(e) { return ''; } } function clearPendingSlug() { try { localStorage.removeItem(LS_KEY_PENDING_SLUG); } catch(e) {} } function tryAutoAddFromPending() { const pending = getPendingSlug(); if (!pending) return; const boxes = Array.from(document.querySelectorAll('[product-data]')); const target = boxes.find(b => { const isCard = (b.getAttribute('product-data') || '').trim().toLowerCase() === 'card'; return !isCard && readProductSlug(b) === pending; }); if (!target) return; const key = getBoxKey(target); const inCart = Cart.items.some(i => `${i.id}__${i.variant || ''}` === key); if (inCart) { clearPendingSlug(); try { showCartArea(); } catch(e) {} return; } if (!isProductAvailableByBox(target)) { clearPendingSlug(); return; } const data = collectProductFrom(target); Cart.add(data); clearPendingSlug(); try { showCartArea(); } catch(e) {} } // ===== Bind ===== function bindAddToCart() { document.querySelectorAll('[product-data]').forEach((box) => { initProductCount(box); initProductOptions(box); const isCard = (box.getAttribute('product-data') || '').trim().toLowerCase() === 'card'; const slug = readProductSlug(box); box.querySelectorAll('[add-to-cart]').forEach((btn) => { const labelNode = getAddToCartLabelNode(btn); if (!btn.dataset.addLabel) { btn.dataset.addLabel = (labelNode.textContent || '').trim(); } btn.addEventListener('click', (e) => { const available = isProductAvailableByBox(box); if (!available) { e.preventDefault(); return; } const key = getBoxKey(box); const inCart = Cart.items.some(i => `${i.id}__${i.variant || ''}` === key); if (inCart) { e.preventDefault(); try { showCartArea(); } catch(e) {} return; } if (isCard) { setPendingSlug(slug); return; } e.preventDefault(); const data = collectProductFrom(box); Cart.add(data); refreshAddToCartStates(); try { showCartArea(); } catch(e) {} }); }); }); document.querySelectorAll('[cart-clear]').forEach(btn => { btn.addEventListener('click', () => Cart.clear()); }); refreshAvailabilityForAll(); } // ===== Live watch for availability source ===== function startAvailabilityWatcher() { let lastRef = getAvailabilitySource(); let lastSig = djb2(safeStringify(lastRef?.map || lastRef || {})); const tick = () => { const cur = getAvailabilitySource(); const curSig = djb2(safeStringify(cur?.map || cur || {})); const changed = (cur !== lastRef) || (curSig !== lastSig); if (changed) { lastRef = cur; lastSig = curSig; refreshAvailabilityForAll(); refreshAddToCartStates(); renderAll(); } }; setInterval(tick, AVAIL_POLL_MS); window.addEventListener('product-availability:updated', () => { lastSig = ''; tick(); }); } // ===== Observe attribute changes on product-data ===== function startAttrObserver() { const observer = new MutationObserver((mutations) => { let needRerender = false; for (const m of mutations) { if (m.type === 'attributes' && (m.attributeName === 'product-avialability-city' || m.attributeName === 'product-avialability-street')) { const box = m.target.closest('[product-data]') || m.target; applyAvailabilityToBox(box); refreshAddToCartStates(); needRerender = true; } } if (needRerender) renderAll(); }); document.querySelectorAll('[product-data]').forEach((box) => { observer.observe(box, { attributes: true, attributeFilter: ['product-avialability-city', 'product-avialability-street'] }); }); const rootObserver = new MutationObserver(() => { document.querySelectorAll('[product-data]').forEach((box) => { observer.observe(box, { attributes: true, attributeFilter: ['product-avialability-city', 'product-avialability-street'] }); }); updateGoToCheckoutControls(); }); rootObserver.observe(document.documentElement, { childList: true, subtree: true }); } // ==== Console helper ==== function cartCheck() { try { if (!window.CHECKOUT_CART_JSON) buildCheckoutJSON(); console.log('[CHECKOUT_CART_JSON]', window.CHECKOUT_CART_JSON); console.log('[GO_TO_CHECKOUT_STATUS]', window.GO_TO_CHECKOUT_STATUS); return window.CHECKOUT_CART_JSON; } catch(e) { console.warn('cart-check failed', e); } } window.cartCheck = cartCheck; try { window['cart-check'] = cartCheck; } catch(e) {} // ===== Init ===== function init() { Cart.load(); bindAddToCart(); bindCartOpenClose(); renderAll(); const onCityStreetChange = () => { const carts = Array.from(document.querySelectorAll('[cart-data]')); for (const c of carts) { const city = (c.getAttribute('product-avialability-city') || '').trim(); const street = (c.getAttribute('product-avialability-street') || '').trim(); if (city || street) { try { localStorage.setItem(LS_KEY_LOCATION, JSON.stringify({city, street})); } catch(e) {} break; } } refreshAvailabilityForAll(); refreshAddToCartStates(); renderAll(); }; window.addEventListener('city:change', onCityStreetChange); window.addEventListener('citystreet:updated', onCityStreetChange); window.addEventListener('citystreet:streets-updated', onCityStreetChange); window.addEventListener('product-availability:updated', onCityStreetChange); startAvailabilityWatcher(); startAttrObserver(); tryAutoAddFromPending(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();