/* CANTIK promo_scope: скидка промокода только по отмеченным товарам/категориям.
*
* Правила приходят с панели: https://rayprism.ru/cantik/promo-rules.json
* { updated: ": { 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();
})();