/* CANTIK promo_scope: скидка промокода только по отмеченным товарам/категориям. * * Правила приходят с панели: https://rayprism.ru/cantik/promo-rules.json * { updated: , codes: { "": { enabled, mode, items, note, label } } } * mode: products | categories | except_products | except_categories. * label — готовое пояснение для клиента (выводится под полем промокода). * * Оборачиваются ОБЕ реализации расчёта Tilda: * 1) CartCalculator.prototype._calcPromocode (proto-swap — переживает * пересоздание window.cartCalculator); * 2) глобальная tcart__calcPromocode (оперирует window.tcart — на время * расчёта подменяем window.tcart на subset-копию, потом восстанавливаем). * * Семантика: позиции вне правила оплачиваются полностью; скидка считается * оригиналом Tilda по «подмножеству» (fixed концентрируется на eligible, * percent — только по eligible). Любой сбой → fail-open (штатный расчёт). * Вставьте в Head-code проекта до tilda-cart. */ (function () { "use strict"; if (window.__cantikPromoScope) { return; } window.__cantikPromoScope = true; var RULES_URL = "https://rayprism.ru/cantik/promo-rules.json"; var LS_KEY = "cantik_promo_rules"; var LS_TTL = 24 * 60 * 60 * 1000; var XHR_TIMEOUT = 8000; var POLL_MS = 200, POLL_MAX = 150; var rules = null; /* {CODE: {enabled,mode,items,note}} */ var rulesSrc = null; /* 'ls' | 'fetch' — для отладки */ function dbg() { if (window.__cantikPromoScopeDebug && window.console && console.log) { var a = ["[promoScope]"].concat([].slice.call(arguments)); console.log.apply(console, a); } } /* ---------- helpers (pure, доступны тестам) ---------- */ function norm(v) { return v === undefined || v === null ? "" : String(v).trim().toUpperCase(); } function toSet(arr) { var s = Object.create(null), i; for (i = 0; i < arr.length; i++) { s[norm(arr[i])] = true; } return s; } function num(v) { var n = parseFloat(v); return isFinite(n) ? n : 0; } function ruleFor(code) { if (!rules || !code) { return null; } var c = String(code).trim(); var r = rules[c]; if (!r) { /* case-insensitive fallback */ var up = norm(c), k; for (k in rules) { if (Object.prototype.hasOwnProperty.call(rules, k) && norm(k) === up) { r = rules[k]; break; } } } if (!r || r.enabled === false || !r.items || !r.items.length) { return null; } return r; } function validProducts(cart) { var out = [], ps = (cart && cart.products) || [], i, p; for (i = 0; i < ps.length; i++) { p = ps[i]; if (!p || typeof p !== "object") { continue; } if (p.deleted === "yes") { continue; } if (Object.keys(p).length === 0) { continue; } out.push(p); } return out; } function partsOf(p) { var raw = p.part_uids; if (typeof raw === "string") { raw = raw.split(","); } if (!Array.isArray(raw)) { return []; } var out = [], i, s; for (i = 0; i < raw.length; i++) { s = String(raw[i] === undefined || raw[i] === null ? "" : raw[i]).trim(); if (s) { out.push(s); } } return out; } function uidHit(p, set) { return (p.uid !== undefined && p.uid !== null && set[norm(p.uid)]) || (p.gen_uid !== undefined && p.gen_uid !== null && set[norm(p.gen_uid)]); } function partsHit(p, set) { var ps = partsOf(p), i; for (i = 0; i < ps.length; i++) { if (set[norm(ps[i])]) { return true; } } return false; } function eligible(p, rule) { if (!rule) { return true; } var items = rule.items || [], set = toSet(items), m = rule.mode; if (m === "products") { return uidHit(p, set); } if (m === "categories") { return partsHit(p, set); } if (m === "except_products") { return !uidHit(p, set); } if (m === "except_categories") { return !partsHit(p, set); } return true; } function lineAmount(t) { if (t.amount !== undefined && t.amount !== null && isFinite(parseFloat(t.amount))) { return num(t.amount); } if (t.amount_withdiscount !== undefined && t.amount_withdiscount !== null && isFinite(parseFloat(t.amount_withdiscount))) { return num(t.amount_withdiscount); } return num(t.price) * num(t.quantity); } function lineAmountWD(t) { if (t.amount_withdiscount !== undefined && t.amount_withdiscount !== null && isFinite(parseFloat(t.amount_withdiscount))) { return num(t.amount_withdiscount); } return lineAmount(t); } function shallow(o) { var c = {}, k; for (k in o) { if (Object.prototype.hasOwnProperty.call(o, k)) { c[k] = o[k]; } } return c; } function clamp(v, lo, hi) { if (typeof v !== "number" || !isFinite(v)) { return null; } /* NaN-guard */ if (v < lo) { v = lo; } if (hi !== undefined && v > hi) { v = hi; } return v; } /* ---------- выбор subset ---------- */ function selectSubset(cart, rule) { var all = validProducts(cart), sel = [], i; for (i = 0; i < all.length; i++) { if (eligible(all[i], rule)) { sel.push(all[i]); } } return { all: all, sel: sel }; } function hasDynBasis(realCart, discountsLen) { return discountsLen > 0 && realCart && realCart.prodamount_withdyndiscount !== undefined; } function buildSub(cart, sel, dyn) { var sub = shallow(cart), i, sumA = 0, sumW = 0; sub.products = sel; sub.promocode = shallow(cart.promocode || {}); for (i = 0; i < sel.length; i++) { sumA += lineAmount(sel[i]); sumW += lineAmountWD(sel[i]); } sumA = Math.round(sumA * 100) / 100; sumW = Math.round(sumW * 100) / 100; sub.prodamount = sumA; if (dyn) { sub.prodamount_withdyndiscount = sumW; } return sub; } /* ---------- метод CartCalculator.prototype._calcPromocode ---------- */ function scopedCalc(orig, self, base, cart) { try { var promo = cart && cart.promocode; if (!promo || typeof promo !== "object") { updateNote(null); return orig.call(self, base, cart); } var rule = ruleFor(promo.promocode !== undefined && promo.promocode !== null ? promo.promocode : promo.code); updateNote(rule); if (!rule) { return orig.call(self, base, cart); } var r = selectSubset(cart, rule); var n = r.all.length, k = r.sel.length; if (k === 0) { /* ничего не подходит — скидки нет, исключённые платят полную цену */ cart.prodamount_discountsum = 0; cart.prodamount_withdiscount = base; if (cart.promocode && typeof cart.promocode === "object") { cart.promocode.prodamount_discountsum = 0; } dbg("method: empty subset, code", promo.promocode, "base", base); return base; } if (k === n) { return orig.call(self, base, cart); } /* всё eligible — штатно */ var dyn = hasDynBasis(cart, (self && self.discounts && self.discounts.length) || 0); var sub = buildSub(cart, r.sel, dyn); var subBase = dyn ? sub.prodamount_withdyndiscount : sub.prodamount; var subsetTotal; try { subsetTotal = orig.call(self, subBase, sub); } catch (e1) { dbg("method: subset orig threw, fail-open", e1); return orig.call(self, base, cart); } subsetTotal = clamp(subsetTotal, 0); if (subsetTotal === null) { dbg("method: NaN subsetTotal, fail-open"); return orig.call(self, base, cart); } var subsetDiscount = clamp(subBase - subsetTotal, 0, subBase); if (subsetDiscount === null) { return orig.call(self, base, cart); } var total = clamp(base - subsetDiscount, 0); if (total === null) { total = 0; } cart.prodamount_discountsum = subsetDiscount; cart.prodamount_withdiscount = total; cart.promocode.prodamount_discountsum = subsetDiscount; dbg("method: code", promo.promocode, "subBase", subBase, "subsetDiscount", subsetDiscount, "total", total); return total; } catch (e) { dbg("method: outer fail-open", e); try { return orig.call(self, base, cart); } catch (e2) { return base; } } } function wrapMethod() { var CC = window.CartCalculator; if (!CC || !CC.prototype) { return false; } var proto = CC.prototype; if (proto.__promoWrapped) { return true; } var orig = proto._calcPromocode; if (typeof orig !== "function") { return false; } var wrapped = function (base, cart) { return scopedCalc(orig, this, base, cart); }; wrapped.__orig = orig; proto._calcPromocode = wrapped; proto.__promoWrapped = true; /* belt&suspenders: у существующего инстанса может быть own-prop */ var inst = window.cartCalculator; if (inst && Object.prototype.hasOwnProperty.call(inst, "_calcPromocode")) { inst._calcPromocode = wrapped; } dbg("CartCalculator.prototype._calcPromocode wrapped"); return true; } /* ---------- глобальная tcart__calcPromocode (swap window.tcart) ---------- */ function scopedCalcGlobal(orig, base) { try { var real = window.tcart; if (!real || typeof real !== "object") { return orig(base); } var promo = real.promocode; if (!promo || typeof promo !== "object") { updateNote(null); return orig(base); } var rule = ruleFor(promo.promocode !== undefined && promo.promocode !== null ? promo.promocode : promo.code); updateNote(rule); if (!rule) { return orig(base); } var r = selectSubset(real, rule); var n = r.all.length, k = r.sel.length; if (k === 0) { real.prodamount_discountsum = 0; real.prodamount_withdiscount = base; if (real.promocode && typeof real.promocode === "object") { real.promocode.prodamount_discountsum = 0; } updateDiscountDom(0); dbg("global: empty subset, base", base); return base; } if (k === n) { return orig(base); } var dyn = hasDynBasis(real, (window.t_cart__discounts && window.t_cart__discounts.length) || 0); var sub = buildSub(real, r.sel, dyn); var subBase = dyn ? sub.prodamount_withdyndiscount : sub.prodamount; var subsetTotal = null, thrown = null; try { window.tcart = sub; subsetTotal = orig(subBase); } catch (e1) { thrown = e1; } finally { window.tcart = real; } if (thrown) { dbg("global: subset orig threw, fail-open", thrown); return orig(base); } subsetTotal = clamp(subsetTotal, 0); if (subsetTotal === null) { dbg("global: NaN subsetTotal, fail-open"); return orig(base); } var subsetDiscount = clamp(subBase - subsetTotal, 0, subBase); if (subsetDiscount === null) { return orig(base); } var total = clamp(base - subsetDiscount, 0); if (total === null) { total = 0; } real.prodamount_discountsum = subsetDiscount; real.prodamount_withdiscount = total; real.promocode.prodamount_discountsum = subsetDiscount; updateDiscountDom(subsetDiscount); dbg("global: code", promo.promocode, "subBase", subBase, "subsetDiscount", subsetDiscount, "total", total); return total; } catch (e) { dbg("global: outer fail-open", e); try { return orig(base); } catch (e2) { return base; } } } function updateDiscountDom(value) { try { var el = document.querySelector( ".t-inputpromocode__wrapper .t706__cartwin-prodamount-price:nth-last-child(2)"); if (el) { el.innerHTML = value; } } catch (e) { /* DOM может отсутствовать (нет окна корзины) */ } } /* ---------- пояснение для клиента (почему скидка ≠ процент кода) ---------- */ var NOTE_ID = "cantik-promo-note"; function noteText(rule) { if (!rule) { return ""; } if (rule.label) { return String(rule.label); } /* фолбэк, если сервер не прислал label (старый кэш JSON) */ var m = rule.mode; if (m === "except_categories" || m === "except_products") { return "Промокод действует не на все товары корзины"; } if (m === "categories" || m === "products") { return "Скидка применяется не ко всей корзине"; } return ""; } function updateNote(rule) { try { var text = noteText(rule); var el = document.getElementById(NOTE_ID); if (!text) { if (el && el.parentNode) { el.parentNode.removeChild(el); } return; } var host = document.querySelector(".t-inputpromocode__wrapper"); if (!host || !host.parentNode) { return; } if (!el) { el = document.createElement("div"); el.id = NOTE_ID; el.style.cssText = "font-size:12px;line-height:1.45;color:#7a746a;" + "margin-top:6px;max-width:100%"; host.parentNode.insertBefore(el, host.nextSibling); } if (el.textContent !== text) { el.textContent = text; } /* не HTML */ } catch (e) { /* DOM не готов — не мешаем расчёту */ } } function wrapGlobal() { var orig = window.tcart__calcPromocode; if (typeof orig !== "function") { return false; } if (orig.__promoWrapped) { return true; } var wrapped = function (base) { return scopedCalcGlobal(orig, base); }; wrapped.__orig = orig; wrapped.__promoWrapped = true; window.tcart__calcPromocode = wrapped; dbg("tcart__calcPromocode wrapped"); return true; } /* ---------- rules: LS last-good + XHR ---------- */ function readLs() { try { var raw = localStorage.getItem(LS_KEY); if (!raw) { return null; } var d = JSON.parse(raw); if (!d || typeof d !== "object") { return null; } if (typeof d.fetched !== "number") { return null; } if (Date.now() - d.fetched > LS_TTL) { return null; } if (!d.codes || typeof d.codes !== "object") { return null; } return d.codes; } catch (e) { return null; } } function saveLs(codes) { try { localStorage.setItem(LS_KEY, JSON.stringify({ fetched: Date.now(), codes: codes })); } catch (e) { /* quota/private mode */ } } function setRules(codes, src) { if (codes && typeof codes === "object") { rules = codes; rulesSrc = src || "set"; } } var lastFetch = 0; function fetchRules() { /* троттлинг 30с: pageshow/focus/visibilitychange не долбят сервер */ var now = Date.now(); if (now - lastFetch < 30000) { return; } lastFetch = now; try { var xhr = new XMLHttpRequest(); xhr.open("GET", RULES_URL, true); xhr.timeout = XHR_TIMEOUT; xhr.onreadystatechange = function () { if (xhr.readyState !== 4) { return; } if (xhr.status >= 200 && xhr.status < 300) { try { var d = JSON.parse(xhr.responseText); if (d && d.codes && typeof d.codes === "object") { setRules(d.codes, "fetch"); saveLs(d.codes); dbg("rules fetched:", Object.keys(d.codes).length, "codes"); } else { dbg("rules: bad payload"); } } catch (e) { dbg("rules: JSON parse error", e); } } else { dbg("rules: HTTP", xhr.status, "(keeping last-good)"); } }; xhr.onerror = function () { dbg("rules: network error (keeping last-good)"); }; xhr.ontimeout = function () { dbg("rules: timeout (keeping last-good)"); }; xhr.send(null); } catch (e) { dbg("rules: xhr exception", e); } } /* ---------- install ---------- */ function installOnce() { var okM = wrapMethod(); var okG = wrapGlobal(); return okM; /* ждём появления CartCalculator; глобальная — по мере возможности */ } function boot() { /* 1. синхронный last-good из LS — до первой операции с корзиной */ var ls = readLs(); if (ls) { setRules(ls, "ls"); } /* 2. асинхронный свежий снимок (при фейле LS не затирается) */ fetchRules(); /* 2b. прогрев/обновление при возврате на вкладку (bfcache, фокус) — правила почти всегда готовы ДО того, как клиент нажмёт «Активировать», и всегда свежие после правки в панели */ try { var refresh = function () { try { if (document.visibilityState === "hidden") { return; } } catch (e) { /* нет document — ок */ } fetchRules(); }; window.addEventListener("pageshow", refresh); window.addEventListener("focus", refresh); document.addEventListener("visibilitychange", refresh); } catch (e) { /* среда без events (тесты) — ок */ } /* 3. poll: CartCalculator появляется после загрузки tilda-cart */ var tries = 0; var timer = setInterval(function () { tries++; var done = installOnce(); if (done || tries >= POLL_MAX) { clearInterval(timer); if (!done) { dbg("install timeout: CartCalculator not found"); } } }, POLL_MS); installOnce(); } /* ---------- test API ---------- */ window.__cantikPromoScopeTest = { eligible: eligible, ruleFor: ruleFor, scopedCalc: scopedCalc, scopedCalcGlobal: scopedCalcGlobal, setRules: setRules, validProducts: validProducts, partsOf: partsOf, norm: norm, wrapMethod: wrapMethod, wrapGlobal: wrapGlobal, getRules: function () { return rules; }, getRulesSrc: function () { return rulesSrc; }, selectSubset: selectSubset, buildSub: buildSub, noteText: noteText, updateNote: updateNote, fetchRules: fetchRules }; boot(); })();
Подпишитесь на рассылку
Новые коллекции, акции и распродажи — первыми в вашей почте