/* ============================================================ galleryfloor.ru — код для ВСЕГО САЙТА Настройки сайта → Ещё → HTML-код внутрь HEAD 1) цели Яндекс Метрики (счётчик 103423666) 2) компактный калькулятор упаковок на карточке товара Блоки изолированы: сбой одного не мешает другому. ============================================================ */ /* ---------- 1. ЦЕЛИ МЕТРИКИ ---------- */ try { (function () { "use strict"; var YM_ID = 103423666; var DEBUG = false; // true — цели видно в консоли браузера (F12) // Страницы «Спасибо». На них попадают и после заявки, и после заказа. var THANK_YOU_PATHS = ["/zakaz"]; // --- какая это страница --- var path = (location.pathname || "/").toLowerCase(); var isMain = (path === "/" || path === "/index.html" || path === ""); var SUFFIX = isMain ? "_main" : "_product"; var GOAL_LEAD = "lead"; // заявка отправлена — главная цель var GOAL_LEAD_OPEN = "lead_form_open"; // открыл попап с формой var GOAL_ADD_TO_CART = "add_to_cart"; var GOAL_CHECKOUT = "checkout_start"; var GOAL_PURCHASE = "purchase"; var GOAL_MESSENGER = "messenger" + SUFFIX; var GOAL_PHONE = "phone_click"; /* ============ ОЧЕРЕДЬ ЦЕЛЕЙ ============ Тильда запускает счётчик не сразу: код Метрики ждёт согласия на аналитические куки, а после согласия — ещё 2 секунды. Всё, что посетитель успел сделать раньше, терялось бы безвозвратно. Поэтому цели складываются в очередь и уходят, как только счётчик появится — в том числе уже на следующей странице. */ var QUEUE_KEY = "gf_goal_queue"; var QUEUE_TTL_MS = 2 * 60 * 60 * 1000; function loadQueue() { try { var raw = localStorage.getItem(QUEUE_KEY); if (!raw) return []; var q = JSON.parse(raw); if (!q || !q.length) return []; var now = Date.now(); return q.filter(function (g) { return now - (g.ts || 0) < QUEUE_TTL_MS; }); } catch (e) { return []; } } function saveQueue(q) { try { if (q && q.length) { localStorage.setItem(QUEUE_KEY, JSON.stringify(q)); } else { localStorage.removeItem(QUEUE_KEY); } } catch (e) {} } function flushQueue() { if (typeof window.ym !== "function") return; var q = loadQueue(); if (!q.length) return; saveQueue([]); for (var i = 0; i < q.length; i++) { try { if (q[i].params) { window.ym(YM_ID, "reachGoal", q[i].name, q[i].params); } else { window.ym(YM_ID, "reachGoal", q[i].name); } if (DEBUG && window.console) { console.log("[YM] отложенная цель: " + q[i].name); } } catch (e) {} } } function goal(name, params) { try { if (typeof window.ym === "function") { if (params) { window.ym(YM_ID, "reachGoal", name, params); } else { window.ym(YM_ID, "reachGoal", name); } } else { var q = loadQueue(); q.push({ name: name, params: params || null, ts: Date.now() }); saveQueue(q); } if (DEBUG && window.console) { console.log("[YM] " + name, params || ""); } } catch (e) {} } flushQueue(); setInterval(flushQueue, 700); var lastFire = {}; function fireOnce(name, params) { var now = Date.now(); if (lastFire[name] && now - lastFire[name] < 800) return; lastFire[name] = now; goal(name, params); } /* ============ КОРЗИНА ============ */ function cartQty() { try { if (!window.tcart || !window.tcart.products) return 0; var q = 0; for (var i = 0; i < window.tcart.products.length; i++) { q += Number(window.tcart.products[i].quantity) || 0; } return q; } catch (e) { return 0; } } function cartAmount() { try { return Number(window.tcart && window.tcart.prodamount) || 0; } catch (e) { return 0; } } function lastProduct() { try { var p = window.tcart.products[window.tcart.products.length - 1]; return p ? { name: p.name, price: Number(p.price) || 0 } : null; } catch (e) { return null; } } var prevQty = null; setInterval(function () { var q = cartQty(); if (prevQty === null) { prevQty = q; return; } if (q > prevQty) { var p = lastProduct(); fireOnce(GOAL_ADD_TO_CART, { product: p ? p.name : "", price: p ? p.price : 0, added: q - prevQty, cart_qty: q, cart_amount: cartAmount() }); } prevQty = q; }, 600); /* ============ ЧТО ИМЕННО ОТПРАВИЛИ ============ Форма заявки и корзина ведут на одну страницу «Спасибо». Поэтому перед отправкой запоминаем тип действия, а на странице благодарности засчитываем соответствующую цель. */ var PENDING_KEY = "gf_pending_action"; var PENDING_TTL_MS = 6 * 60 * 60 * 1000; function savePending(data) { try { localStorage.setItem(PENDING_KEY, JSON.stringify(data)); } catch (e) {} } function readPending() { try { var raw = localStorage.getItem(PENDING_KEY); if (!raw) return null; var p = JSON.parse(raw); if (!p || Date.now() - (p.ts || 0) > PENDING_TTL_MS) { localStorage.removeItem(PENDING_KEY); return null; } return p; } catch (e) { return null; } } function clearPending() { try { localStorage.removeItem(PENDING_KEY); } catch (e) {} } // Форма корзины Тильды (блок T706). В каждой категории своя форма // со своим id, поэтому проверяем блок-контейнер, а не id. function isCartForm(form) { if (!form || !form.closest) return false; if (form.closest(".t706, .t706__cartpage, .t706__cartwin")) return true; return (form.className || "").toString().indexOf("t706") > -1; } // Любая другая форма Тильды считается формой заявки function isLeadForm(form) { if (!form || isCartForm(form)) return false; var cls = (form.className || "").toString(); if (cls.indexOf("t-form") > -1) return true; return !!(form.querySelector && form.querySelector('[name="messenger-type"], [data-field-type="contact_method"]')); } // Выбранный способ связи: Телефон / Telegram / WhatsApp / Max function contactMethod(form) { try { var r = form.querySelector('input[name="messenger-type"]:checked'); var v = r ? (r.value || "") : ""; if (!v) { var active = form.querySelector('.t-contact-method__type.t-active, [data-method-type].t-active'); if (active) { v = active.getAttribute("data-method-type") || ""; } } v = v.toLowerCase(); if (v === "max_messenger") { v = "max"; } return v || "unknown"; } catch (e) { return "unknown"; } } function rememberSubmit(form) { if (!form) return; if (isCartForm(form)) { var amount = cartAmount(), qty = cartQty(); savePending({ type: "order", amount: amount, qty: qty, from: location.pathname, ts: Date.now() }); fireOnce(GOAL_CHECKOUT, { amount: amount, qty: qty }); } else if (isLeadForm(form)) { savePending({ type: "lead", method: contactMethod(form), from: location.pathname, ts: Date.now() }); } } document.addEventListener("submit", function (e) { rememberSubmit(e.target); }, true); /* ============ УСПЕШНАЯ ОТПРАВКА ============ Заявка: Тильда показывает «Спасибо! Заявка принята» прямо в попапе, никуда не переходя. Ловим событие формы. Заказ: корзина уводит на страницу /zakaz. Там и засчитываем покупку. На каждый путь есть запасной вариант, чтобы конверсия не потерялась, и защита от повторной отправки одной и той же цели. */ var lastLeadTs = 0, lastPurchaseTs = 0; var DEDUP_MS = 5000; function fireLead(method, from) { var now = Date.now(); if (now - lastLeadTs < DEDUP_MS) return; lastLeadTs = now; clearPending(); goal(GOAL_LEAD, { contact_method: method || "unknown", from: from || location.pathname, page_path: location.pathname }); } function firePurchase(amount, qty) { var now = Date.now(); if (now - lastPurchaseTs < DEDUP_MS) return; lastPurchaseTs = now; clearPending(); goal(GOAL_PURCHASE, { order_price: amount || 0, currency: "RUB", qty: qty || 0 }); } function isThankYouPage() { var p = (location.pathname || "").toLowerCase(); for (var i = 0; i < THANK_YOU_PATHS.length; i++) { if (p.indexOf(THANK_YOU_PATHS[i]) === 0) return true; } return false; } /* Страница «Спасибо» — сюда приводит только заказ из корзины. Заявка сюда не ведёт, поэтому её маркер считаем действительным лишь несколько минут: так застарелая запись не превратится в ложную заявку во время оформления заказа. */ if (isThankYouPage()) { var pend = readPending(); if (pend) { if (pend.type === "order") { firePurchase(pend.amount, pend.qty); } else if (pend.type === "lead" && Date.now() - (pend.ts || 0) < 5 * 60 * 1000) { fireLead(pend.method, pend.from); } else { clearPending(); } } } /* Основной путь для заявки: событие успешной отправки формы Тильды */ function bindTildaSuccess() { if (!window.jQuery) return false; window.jQuery(document).on("tildaform:aftersuccess", function (e) { var form = e.target; if (isCartForm(form)) { firePurchase(cartAmount(), cartQty()); } else { fireLead(contactMethod(form), location.pathname); } }); return true; } if (!bindTildaSuccess()) { var tries = 0; var jqWait = setInterval(function () { if (bindTildaSuccess() || ++tries > 40) { clearInterval(jqWait); } }, 300); } /* Запасной путь: на экране появился блок «Спасибо, заявка принята». Страхует на случай, если событие формы почему-то не сработает. */ if (window.MutationObserver) { new MutationObserver(function (muts) { for (var i = 0; i < muts.length; i++) { var el = muts[i].target; if (!el || !el.className || !el.closest) continue; var cls = el.className.toString(); if (cls.indexOf("successbox") === -1 && cls.indexOf("t706__success") === -1) continue; if (el.offsetParent === null) continue; // блок скрыт — не считаем var form = el.closest("form"); if (!form) { var box = el.closest(".t-popup, .t706, .t-form__wrapper"); if (box) { form = box.querySelector("form"); } } if (isCartForm(form)) { firePurchase(cartAmount(), cartQty()); } else { fireLead(form ? contactMethod(form) : "unknown", location.pathname); } } }).observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ["style", "class"] }); } /* ============ КЛИКИ ============ */ function detectMessenger(href) { if (!href) return null; var h = href.toLowerCase(); if (h.indexOf("max.ru") > -1) return "max"; if (h.indexOf("t.me") > -1 || h.indexOf("telegram") > -1) return "telegram"; if (h.indexOf("wa.me") > -1 || h.indexOf("whatsapp") > -1) return "whatsapp"; if (h.indexOf("vk.me") > -1) return "vk"; if (h.indexOf("viber") > -1) return "viber"; return null; } // Клик пришёл из плавающего виджета мессенджеров (блоки T898 / T845) function inWidget(el) { return !!(el && el.closest && el.closest('[class*="t898"], [class*="t845"]')); } document.addEventListener("click", function (e) { var target = e.target; if (!target || !target.closest) return; // кнопка отправки формы — на случай, если событие submit не дойдёт var submitBtn = target.closest(".t-submit, .t-form__submit, button[type='submit'], input[type='submit']"); if (submitBtn) { rememberSubmit(submitBtn.closest("form")); } var link = target.closest("a[href]"); var btn = target.closest("a, button, [class*='t-btn'], .tn-atom"); var href = link ? (link.getAttribute("href") || "") : ""; var widget = inWidget(link) || inWidget(btn); // открытие попапа с формой if (href.indexOf("#popup") === 0) { var label = (btn || link ? (btn || link).innerText || "" : "").trim().slice(0, 40); fireOnce(GOAL_LEAD_OPEN, { label: label, page: isMain ? "main" : "product", page_path: location.pathname }); return; } if (href.toLowerCase().indexOf("tel:") === 0) { fireOnce(GOAL_PHONE, { phone: href.replace(/^tel:/i, ""), source: widget ? "widget" : "link", page: isMain ? "main" : "product", page_path: location.pathname }); return; } var messenger = detectMessenger(href); if (messenger) { fireOnce(GOAL_MESSENGER, { messenger: messenger, source: widget ? "widget" : "link", page: isMain ? "main" : "product", page_path: location.pathname }); } }, true); if (DEBUG && window.console) { console.log("[YM] цели подключены, страница: " + (isMain ? "главная" : "внутренняя")); } })(); } catch (e) { if (window.console && console.warn) { console.warn('[цели] блок не запустился:', e); } } /* ---------- 2. КАЛЬКУЛЯТОР УПАКОВОК НА КАРТОЧКЕ ---------- */ try { /*! * gf-calc.js — калькулятор «площадь помещения → упаковки» для galleryfloor.ru * Tilda + Tilda Catalog (блок t-catalog, корзина t706). * * Зачем: цена в карточке указана за 1 м², и родная кнопка кладёт 1 м². * Покупатель не знает, сколько это упаковок. Калькулятор считает упаковки, * округляет вверх и кладёт в корзину ровно нужное количество. * * Без зависимостей. Вставляется один раз на весь сайт. */ (function () { 'use strict'; if (window.__gfCalcLoaded) { return; } window.__gfCalcLoaded = true; /* ======================= НАСТРОЙКИ ======================= */ var CONFIG = { // Запас на подрезку. Одно значение: выбор способа укладки на карточке // только мешал. Чтобы изменить процент, поправьте число (0.07 = 7%). RESERVE: 0.07, MIN_AREA: 0.5, // м², минимум для ввода MAX_AREA: 500, // м², максимум // категории, где калькулятор уместен CATEGORIES: ['laminat', 'kvartsvinil', 'inzhenernaya-doska', 'keramogranit'], // Яндекс Метрика: цели отправляются, только если счётчик уже поднялся YM_ID: 103423666, GOAL_USED: 'calc_used', GOAL_ADD: 'calc_add_to_cart', // палитра взята с сайта: #3e505e — основной, #f2f2f9 — фон блоков, // #121d24 — тёмный акцент, #ececec — линии, #9fa8af — приглушённый COLORS: { btn: '#3e505e', accent: '#121d24', text: '#3e505e', muted: '#9fa8af', bg: '#f2f2f9', line: '#ececec' } }; var DEBUG = /[?&]gf_debug=1/.test(location.search); function log() { if (!DEBUG || !window.console) { return; } var a = ['[gf-calc]'].concat([].slice.call(arguments)); console.log.apply(console, a); } function warn(msg, extra) { if (window.console && console.warn) { console.warn('[gf-calc] ' + msg, extra || ''); } } /* ======================= РАЗБОР ДАННЫХ ТОВАРА ======================= */ function currentCategory() { var m = /\/category\/([a-z0-9-]+)\//i.exec(location.pathname); return m ? m[1].toLowerCase() : ''; } /** * Ищем площадь упаковки в описании. * В DOM Tilda отдаёт описание СКЛЕЕННЫМ, без переносов строк: * «Характеристики:Толщина: 10 ммПлощадь в упаковке: 1,84 м²Фактура: дерево» * поэтому ориентируемся не на строки, а на саму фразу «упаковк…» * и берём первое число с единицей площади сразу после неё. * Это же правило корректно разбирает «Площадь в упаковке: 18 шт. / 1,22 м²», * потому что «18 шт.» под единицу площади не подходит. */ function parsePackArea(text) { if (!text) { return null; } var t = String(text).replace(/\s+/g, ' '); var re = /упаковк\S*\s*:?\s*([\s\S]{0,60})/gi; var m; while ((m = re.exec(t)) !== null) { var tail = m[1]; // текст склеен, поэтому сразу за единицей может идти следующее слово var num = /(\d+(?:[.,]\d+)?)\s*(?:кв\.?\s*м|м\s*²|м\s*2)/i.exec(tail); if (num) { var v = toNumber(num[1]); if (v && v > 0.2 && v < 20) { return v; } // отсекаем случайные числа } } return null; } function toNumber(v) { var n = parseFloat(String(v).replace(/\s/g, '').replace(',', '.')); return isFinite(n) ? n : null; } // Корень карточки: одинаков и на странице товара, и в попапе каталога function productRoot(node) { return (node && node.closest && node.closest('.js-catalog-product, .js-product')) || document.querySelector('.js-catalog-product, .js-product'); } function readProduct(root) { if (!root) { return null; } var q = function (sel) { return root.querySelector(sel); }; var priceEl = root.querySelector('[data-product-price-def]') || document.querySelector('[data-product-price-def]'); var price = priceEl ? toNumber(priceEl.getAttribute('data-product-price-def')) : null; var nameEl = q('.js-product-name'); var descrEl = q('.js-catalog-prod-all-text') || q('.js-catalog-prod-text'); // 1) порция товара, если она больше 1 (значит уже задана как площадь упаковки) var portion = toNumber(root.getAttribute('data-product-portion')); var area = (portion && portion > 1) ? portion : null; var source = area ? 'portion' : null; // 2) иначе — из описания if (!area && descrEl) { area = parsePackArea(descrEl.textContent || ''); if (area) { source = 'описание'; } } var variantSel = q('select.js-product-edition-option-variants'); return { root: root, uid: root.getAttribute('data-product-uid') || '', name: nameEl ? nameEl.textContent.trim() : (document.title || '').trim(), price: price, packArea: area, areaSource: source, variant: variantSel ? variantSel.value : '', category: currentCategory() }; } /* ======================= РАСЧЁТ ======================= */ function calculate(roomArea, packArea, price) { var withReserve = roomArea * (1 + CONFIG.RESERVE); if (packArea) { var packs = Math.ceil(withReserve / packArea); // только вверх var total = Math.round(packs * packArea * 100) / 100; // без хвостов плавающей точки return { mode: 'packs', packs: packs, area: total, sum: total * (price || 0), withReserve: withReserve }; } // режим без упаковок: керамогранит и инженерная доска var area = Math.ceil(withReserve * 100) / 100; return { mode: 'area', packs: null, area: area, sum: area * (price || 0), withReserve: withReserve }; } function fmtNum(n, dec) { var s = (Math.round(n * 100) / 100).toFixed(dec === undefined ? 2 : dec); return s.replace('.', ',').replace(/,00$/, ''); } function fmtMoney(n) { return Math.round(n).toLocaleString('ru-RU'); } /* ======================= МЕТРИКА ======================= */ function goal(name, params) { try { if (typeof window.ym !== 'function') { return; } // куки не приняты — молча пропускаем if (params) { window.ym(CONFIG.YM_ID, 'reachGoal', name, params); } else { window.ym(CONFIG.YM_ID, 'reachGoal', name); } log('цель', name, params || ''); } catch (e) {} } /* ======================= КОРЗИНА ======================= */ function cartCount() { try { return (window.tcart && window.tcart.products || []).length; } catch (e) { return 0; } } function setExactQuantity(qty) { try { var prods = window.tcart && window.tcart.products; if (!prods || !prods.length) { return false; } var item = prods[prods.length - 1]; // только что добавленная позиция item.quantity = Math.round(qty * 100) / 100; item.amount = Math.round(item.quantity * (parseFloat(item.price) || 0) * 100) / 100; callTilda('tcart__updateTotalProductsinCartObj'); callTilda('tcart__saveLocalObj'); callTilda('tcart__reDrawProducts'); callTilda('tcart__reDrawTotal'); callTilda('tcart__reDrawCartIcon'); log('количество в корзине выставлено:', qty, '→ сумма', item.amount); return true; } catch (e) { warn('не удалось выставить точное количество', e); return false; } } function callTilda(fn) { try { if (typeof window[fn] === 'function') { window[fn](); } } catch (e) {} } function fireEvent(el, type) { var ev; try { ev = new Event(type, { bubbles: true }); } catch (e) { ev = document.createEvent('Event'); ev.initEvent(type, true, true); } el.dispatchEvent(ev); } /* ======================= ВЁРСТКА ======================= */ var STYLE_ID = 'gf-calc-style'; function injectStyle() { if (document.getElementById(STYLE_ID)) { return; } var c = CONFIG.COLORS; var css = [ '.gf-calc{margin:14px 0;padding:14px 16px;border-radius:12px;background:#fff;', 'border:1px solid ' + c.line + ';', 'font-family:Montserrat,Arial,sans-serif;color:' + c.text + ';box-sizing:border-box;', 'max-width:520px;text-align:left}', '.gf-calc *{box-sizing:border-box}', '.gf-calc__line{display:flex;align-items:center;gap:10px}', '.gf-calc__lab{font-size:13px;color:' + c.muted + ';white-space:nowrap}', /* 16px — иначе iOS зумит страницу при фокусе */ '.gf-calc__input{width:92px;height:40px;padding:0 14px;font-size:16px;font-family:inherit;', 'color:' + c.text + ';background:' + c.bg + ';border:1px solid ' + c.line + ';border-radius:30px;outline:none;', 'transition:border-color .15s ease;text-align:center}', '.gf-calc__input:focus{border-color:' + c.text + '}', '.gf-calc__input_err{border-color:#d66}', '.gf-calc__res{display:none;align-items:baseline;gap:8px;flex-wrap:wrap;margin-top:10px}', '.gf-calc__res_on{display:flex}', '.gf-calc__packs{font-size:17px;font-weight:600;color:' + c.accent + ';white-space:nowrap}', '.gf-calc__meta{font-size:14px;white-space:nowrap}', '.gf-calc__sum{font-weight:600}', '.gf-calc__note{font-size:11px;color:' + c.muted + ';margin-top:8px;line-height:1.4}', '.t-catalog__prod-popup__btn-wrapper,.t-store__prod-popup__btn-wrapper{align-items:center}', '.gf-cart-badge{display:inline-flex;flex-direction:column;justify-content:center;min-height:46px;', 'padding:6px 18px;border-radius:30px;background:#fff;border:1px solid ' + c.line + ';', 'font-family:Montserrat,Arial,sans-serif;line-height:1.25}', '.gf-cart-badge b{font-size:15px;font-weight:600;color:' + c.accent + '}', '.gf-cart-badge small{font-size:11px;color:' + c.muted + '}', '@media screen and (max-width:560px){.gf-calc__line{gap:10px}.gf-calc__input{width:84px}', '.gf-calc__packs{font-size:16px}.gf-calc__meta{font-size:13px}}' ].join(''); var st = document.createElement('style'); st.id = STYLE_ID; st.appendChild(document.createTextNode(css)); document.head.appendChild(st); } function buildCalc(p) { injectStyle(); var box = document.createElement('div'); box.className = 'gf-calc'; box.setAttribute('data-gf-calc', '1'); box.innerHTML = '