(function () { 'use strict'; /* Версия сборки. Поднимать при каждой выкладке: в консоли видно, какая именно копия скрипта работает (README → «Разработка и проверка»). */ var VERSION = 8; var PREFIX = '[GSOC]'; var LOG_BACK_ON = "GSOC.setLog('off')"; /*`debug` (по умолчанию — печатается всё) · `warn` (только предупреждения и ошибки) · `error` (только ошибки) · `off` (полностью тихо). При `error`/`off` не печатается даже строка `itteration: N`.*/ /* ---- Уровень логирования (README → «Как отключить логи») ---- debug — всё (по умолчанию), warn — предупреждения и ошибки, error — только ошибки, off — тихо. Переключается БЕЗ правки кода и перевыкладки: ?gsoc=off в адресе страницы · window.GSOC_LOG = 'off' до загрузки скрипта · localStorage.gsoc_log = 'off' · GSOC.setLog('off') в консоли */ var LOG_LEVELS = { debug: 4, warn: 3, error: 2, off: 0 }; var LOG_LEVEL = resolveLogLevel(); function normalizeLogLevel(value) { var v = String(value == null ? '' : value).toLowerCase().replace(/^\s+|\s+$/g, ''); if (v === 'all' || v === 'true' || v === '1' || v === 'on') return 'debug'; if (v === 'false' || v === '0' || v === 'none' || v === 'silent' || v === 'quiet') return 'off'; return LOG_LEVELS.hasOwnProperty(v) ? v : null; } function resolveLogLevel() { try { var m = /[?&](?:gsoc|gsoc_level)=([a-z0-9]+)/i.exec(safe(function () { return location.search; }, '') || ''); var fromUrl = m ? normalizeLogLevel(m[1]) : null; if (fromUrl) return fromUrl; } catch (e) {} try { var fromWindow = normalizeLogLevel(window.GSOC_LOG); if (fromWindow) return fromWindow; } catch (e) {} try { var fromStore = normalizeLogLevel(localStorage.getItem('gsoc_log')); if (fromStore) return fromStore; } catch (e) {} return 'debug'; } function allowLog(level) { return LOG_LEVELS[LOG_LEVEL] >= LOG_LEVELS[level]; } function log() { if (!allowLog('debug')) return; try { console.log.apply(console, [PREFIX].concat([].slice.call(arguments))); } catch (e) {} } function warn() { if (!allowLog('warn')) return; try { console.warn.apply(console, [PREFIX].concat([].slice.call(arguments))); } catch (e) {} } function err() { if (!allowLog('error')) return; try { console.error.apply(console, [PREFIX].concat([].slice.call(arguments))); } catch (e) {} } /* Включить/выключить логи на живой странице: GSOC.setLog('off' | 'warn' | 'error' | 'debug'). */ function setLogLevel(value) { var level = normalizeLogLevel(value); if (!level) return LOG_LEVEL; LOG_LEVEL = level; try { localStorage.setItem('gsoc_log', level); } catch (e) {} if (allowLog('warn')) { try { console.log(PREFIX, 'уровень логов: ' + level + ' (запомнил в localStorage.gsoc_log; вернуть полностью — ' + LOG_BACK_ON + ' или ?gsoc=debug в адресе)'); } catch (e) {} } return LOG_LEVEL; } /* Первая строка — версия сборки: печатается, пока уровень не ниже warn. */ if (allowLog('warn')) { try { console.log('itteration: ' + VERSION); } catch (e) {} } /* --- вспомогательные функции для логов (не должны ломать работу скрипта) --- */ /* Безопасный вызов: нет API в окружении — лог всё равно не падает. */ function safe(fn, fallback) { try { var v = fn(); return (v == null || v === '') ? (fallback == null ? '—' : fallback) : String(v); } catch (e) { return fallback == null ? '—' : fallback; } } /* Короткая строка для логов: JSON или текст с обрезкой «хвоста». */ function brief(value, limit) { var s; try { s = (typeof value === 'string') ? value : JSON.stringify(value); } catch (e) { s = String(value); } s = String(s == null ? '' : s); var max = limit || 160; return s.length > max ? s.slice(0, max) + '…(всего ' + s.length + ' симв.)' : s; } /* Что за скрипт, откуда запущен и где выполняется — первое, что нужно видеть в консоли. */ function envInfo() { var place; try { place = (typeof window.parent === 'undefined') ? 'окно' : (window.parent === window ? 'top-окно' : 'iframe'); } catch (e) { place = 'iframe?'; } return 'версия=' + VERSION + ', url=' + safe(function () { return location.href; }) + ', путь=' + safe(function () { return location.pathname; }) + ', окно=' + place + ', readyState=' + safe(function () { return document.readyState; }) + ', скрипт=' + safe(function () { return (document.currentScript && document.currentScript.src) || 'inline (вшит в страницу)'; }) + ', пользователь=' + safe(function () { return currentUserId(); }, 'гость') + ', лог=' + LOG_LEVEL; } var CONFIG = { STORE_PATH: '/_ofert-admin', MARKER: '.gsoc-settings', KEY_SETTINGS: 'gsoc_settings_cache', SETTINGS_TTL: 5 * 60 * 1000, DEFAULT_STYLE: { bg: '#FFFFFF', text: '#333333', link: '#337AB7', fontSize: 14, radius: 4, borderColor: '#DDDDDD', borderWidth: 1 }, SNAPSHOT_TITLE: 'Вы дали следующие согласия', PAY_PATH: /\/sales\/shop\/dealPay\//, PAY_ROOTS: ['.allPayments', '.xdget-payform', '#pay-methods', 'form[action*="Init"]', '#payment-page', '.payment-page'], PAY_BUTTONS: '.gc-payment-method-button, button.pay-button, button[type=submit], a[href*="Init"], .pay-btn-sber-pay, .pay-btn-sbp-pay, .pay-btn-alpha-pay, .pay-btn-split, .pay-btn-fresh-credit', LOCK_CLASS: 'gsoc-pay-locked', HIDE_CLASS: 'gsoc-pay-hidden', ORDER_CARD_PATH: '/sales/control/userProduct/update/id/', ADD_FIELDS_URL: '/pl/user/user/get-user-additional-form-data?id=', ORDER_BLOCK_ATTR: 'data-gsoc-order-block', CONFIRM_PAGE: '/_ofert-confirm', FIELD: 'user_order_details', /* true — блокируем оплату, пока не отмечены пункты с тумблером «обязательный»; false — требуем отметку ВСЕХ видимых пунктов набора. Переключается без правки кода: поставьте в блоке (или в «счётчиках») перед подключением этого скрипта `window.GSOC_REQUIRE_ALL = true;` — тогда флаг будет выключен. Если в наборе вообще нет обязательных пунктов, оплата не блокируется (в консоль пишется предупреждение с подсказкой, что поправить в админке). */ LOCK_ONLY_REQUIRED: !(typeof window !== 'undefined' && window.GSOC_REQUIRE_ALL === true), READY_TIMEOUT: 8000, SAVE_TIMEOUT: 15000, READ_TIMEOUT: 15000, STYLES_ID: 'gsoc-styles' }; function b64encode(str) { var bytes = new TextEncoder().encode(String(str == null ? '' : str)); var bin = ''; var chunk = 0x8000; for (var i = 0; i < bytes.length; i += chunk) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return btoa(bin); } function b64decode(b64) { var bin = atob(b64); var bytes = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return new TextDecoder().decode(bytes); } function esc(str) { return String(str == null ? '' : str) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function currentUserId() { if (typeof window.accountUserId !== 'undefined' && window.accountUserId) return String(window.accountUserId); if (window.gcUser && window.gcUser.id) return String(window.gcUser.id); return null; } function isAdminViewer() { var ui = window.userInfo || {}; return !!(ui.isAdmin || ui.canCms); } /* Описание DOM-элемента для логов (тег, id, классы). */ function describeEl(el) { if (!el) return '—'; var cls = el.className ? '.' + String(el.className).trim().split(/\s+/).join('.') : ''; var id = el.id ? '#' + el.id : ''; var tag = String(el.tagName || '?').toLowerCase(); return tag + id + cls; } /* Почему скрипт решил, что это (или не это) страница оплаты / карточка заказа. */ function pageSignals() { return brief({ путь: safe(function () { return location.pathname; }), 'PAY_PATH совпал': safe(function () { return CONFIG.PAY_PATH.test(location.pathname); }), 'input[name=deal_id]': safe(function () { return !!document.querySelector('input[name="deal_id"]'); }), 'кнопок .gc-payment-method-button': safe(function () { return document.querySelectorAll('.gc-payment-method-button').length; }), '.xdget-payform': safe(function () { return !!document.querySelector('.xdget-payform'); }), 'ORDER_CARD_PATH совпал': safe(function () { return location.pathname.indexOf(CONFIG.ORDER_CARD_PATH) !== -1; }) }, 240); } function injectStyles() { if (document.getElementById(CONFIG.STYLES_ID)) return; var css = [ '.gsoc-pay{margin:0 0 24px}', '.gsoc-pay-body{border-radius:8px;padding:20px 22px}', '.gsoc-pay-title{font-weight:700;margin:0 0 14px;font-size:1.08em;line-height:1.4}', '.gsoc-pay-item{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;line-height:1.5;cursor:pointer}', '.gsoc-pay-item:last-of-type{margin-bottom:0}', '.gsoc-pay-item input{flex:0 0 auto;width:18px;height:18px;margin:3px 0 0;cursor:pointer}', '.gsoc-pay-body a{color:var(--gsoc-link, inherit);text-decoration:underline;text-underline-offset:2px}', '.gsoc-pay-status{margin-top:14px;font-size:13px;line-height:1.45;opacity:.85}', '.gsoc-pay-status.error{color:#c0392b;opacity:1}', '.gsoc-pay-hidden{display:none !important}', '.gsoc-frame{position:absolute;width:1px;height:1px;left:-9999px;top:0;border:0}', '.gsoc-order-plain .gsoc-pay-body{padding:0;background:transparent;border:0;border-radius:0;color:inherit;font-size:inherit}', '.gsoc-order-plain .gsoc-pay-title{font-weight:700;margin:0 0 8px}', '.gsoc-order-plain .gsoc-pay-item{display:block;cursor:default;margin:0 0 8px;line-height:1.45}', '.gsoc-order-when{margin-top:6px}', '.gsoc-order-empty{font-size:13px;color:#8b8b9e}' ].join(''); var el = document.createElement('style'); el.id = CONFIG.STYLES_ID; el.textContent = css; document.head.appendChild(el); log('стили блока согласий добавлены: ' + CONFIG.STYLES_ID + ', ' + css.length + ' симв.'); } var frame = null; var frameReady = null; /* Диагностика скрытого iframe. Он same-origin, поэтому внутрь можно заглянуть и точно сказать, почему страница подтверждений молчит (нет скрипта / нет формы / нет кода). */ function frameState() { var out = []; try { out.push('src=' + safe(function () { return frame && frame.getAttribute('src'); })); var cdoc = null; var cwin = null; try { cdoc = frame && frame.contentDocument; } catch (e) { cdoc = null; } try { cwin = frame && frame.contentWindow; } catch (e) { cwin = null; } if (!cdoc) { out.push('документ iframe недоступен (другой origin?)'); return out.join(', '); } out.push('readyState=' + safe(function () { return cdoc.readyState; })); out.push('title=' + brief(safe(function () { return cdoc.title; }), 40)); out.push('GSOCConfirm в iframe: ' + (cwin && cwin.GSOCConfirm ? 'есть' : 'НЕТ')); out.push('маркер data-gsoc-confirm=' + safe(function () { return cdoc.documentElement.getAttribute('data-gsoc-confirm'); }, 'НЕТ')); out.push('маркер data-gsoc-admin=' + safe(function () { return cdoc.documentElement.getAttribute('data-gsoc-admin'); }, 'нет')); out.push('GSOC в iframe: ' + (cwin && cwin.GSOC ? 'есть' : 'нет') + ' (его ставит и админка — смотреть на маркеры выше)'); out.push('textarea[name^=formParams[userCustomFields]]: ' + safe(function () { return cdoc.querySelectorAll('textarea[name^="formParams[userCustomFields]"]').length; })); out.push('форма block-public/process: ' + safe(function () { return cdoc.querySelector('form[action*="block-public/process"]') ? 'есть' : 'нет'; })); var html = safe(function () { return cdoc.documentElement ? cdoc.documentElement.innerHTML : ''; }, ''); out.push('упоминания в HTML iframe: postMessage=' + (html.indexOf('postMessage') !== -1) + ', gsoc:ready=' + (html.indexOf('gsoc:ready') !== -1) + ', GSOC-CONFIRM=' + (html.indexOf('GSOC-CONFIRM') !== -1) + ', GSOC(' + PREFIX + ')=' + (html.indexOf(PREFIX) !== -1) + ', update-param=' + (html.indexOf('update-param') !== -1) + ', allPayments=' + (html.indexOf('allPayments') !== -1)); var srcs = []; safe(function () { var list = cdoc.querySelectorAll('script[src]'); for (var i = 0; i < list.length && i < 8; i++) srcs.push(brief(list[i].getAttribute('src'), 60)); return ''; }); if (srcs.length) out.push('внешние скрипты iframe=[' + srcs.join(' | ') + ']'); } catch (e) { out.push('диагностика iframe недоступна: ' + (e && e.message)); } return out.join(', '); } function ensureFrame() { if (frame && frame.parentNode && frameReady) { log('iframe подтверждений уже создан — переиспользую (' + frameState() + ')'); return frameReady; } frame = document.createElement('iframe'); frame.className = 'gsoc-frame'; frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1'); frame.src = CONFIG.CONFIRM_PAGE; document.body.appendChild(frame); log('iframe подтверждений: создаю ' + CONFIG.CONFIRM_PAGE + ', жду gsoc:ready/gsoc:pong не дольше ' + CONFIG.READY_TIMEOUT + ' мс'); frameReady = new Promise(function (resolve, reject) { var done = false; var t0 = Date.now(); var timer = setTimeout(function () { if (done) return; done = true; window.removeEventListener('message', onReady); warn('страница ' + CONFIG.CONFIRM_PAGE + ' не ответила за ' + CONFIG.READY_TIMEOUT + ' мс (ни gsoc:ready, ни gsoc:pong). Диагностика iframe: ' + frameState()); reject(new Error('страница ' + CONFIG.CONFIRM_PAGE + ' не ответила')); }, CONFIG.READY_TIMEOUT); function onReady(e) { if (done) return; if (e.origin !== location.origin) { log('сообщение из чужого origin (' + e.origin + ') — игнорирую'); return; } if (e.source !== frame.contentWindow) return; var d = e.data || {}; if (d.type !== 'gsoc:ready' && d.type !== 'gsoc:pong') return; done = true; clearTimeout(timer); window.removeEventListener('message', onReady); log('страница подтверждений готова: тип=' + d.type + ', за ' + (Date.now() - t0) + ' мс (' + frameState() + ')'); resolve(frame); } window.addEventListener('message', onReady); frame.addEventListener('load', function () { log('iframe подтверждений загружен (load через ' + (Date.now() - t0) + ' мс): ' + frameState() + ' → отправляю gsoc:ping'); try { frame.contentWindow.postMessage({ type: 'gsoc:ping' }, location.origin); } catch (e) { warn('не удалось отправить gsoc:ping: ' + (e && e.message)); } }); }); return frameReady; } function frameRequest(type, payload, answerType, timeout) { var t0 = Date.now(); var limit = timeout || CONFIG.SAVE_TIMEOUT; log('frameRequest: ' + type + ' → ' + CONFIG.CONFIRM_PAGE + ', жду ' + answerType + ' до ' + limit + ' мс' + (payload ? ', payload=' + brief(payload, 120) : '')); return ensureFrame().then(function (fr) { return new Promise(function (resolve, reject) { var timer = setTimeout(function () { window.removeEventListener('message', onReply); warn('frameRequest: ' + answerType + ' не пришёл за ' + (Date.now() - t0) + ' мс. Диагностика iframe: ' + frameState()); reject(new Error('таймаут ответа страницы подтверждений')); }, limit); function onReply(e) { if (e.origin !== location.origin) return; if (e.source !== fr.contentWindow) return; var d = e.data || {}; if (d.type !== answerType) { log('frameRequest: получено сообщение типа ' + d.type + ' — жду ' + answerType); return; } clearTimeout(timer); window.removeEventListener('message', onReply); log('frameRequest: ' + d.type + ' получен за ' + (Date.now() - t0) + ' мс, ok=' + !!d.ok + (d.error ? ', error=' + d.error : '') + (d.result ? ', result=' + brief(d.result, 160) : '')); if (d.ok) resolve(d); else reject(new Error(d.error || 'ошибка страницы подтверждений')); } window.addEventListener('message', onReply); var msg = { type: type }; if (payload) msg.payload = payload; log('запрос к ' + CONFIG.CONFIRM_PAGE + ': ' + type); try { fr.contentWindow.postMessage(msg, location.origin); } catch (e) { warn('frameRequest: postMessage не удался — ' + (e && e.message)); reject(e); } }); }); } function saveToConfirm(payload) { log('saveToConfirm: заказ=' + ((payload && payload.dealId) || '—') + ', предложений=[' + (((payload && payload.offerIds) || []).join(', ') || '—') + ']' + ', пунктов=' + (((payload && payload.items) || []).length) + ', savedAt=' + ((payload && payload.savedAt) || '—')); return frameRequest('gsoc:save', payload, 'gsoc:saved', CONFIG.SAVE_TIMEOUT).then(function (d) { var result = d.result || { ok: true }; log('saveToConfirm: ответ ok=' + !!result.ok + ', formProcessed=' + (result.formProcessed === true) + ', поле=' + (result.field || '—') + ', rawHead=' + brief(result.rawHead, 80)); return result; }); } function readFromConfirm() { log('readFromConfirm: читаю ДП через ' + CONFIG.CONFIRM_PAGE); return frameRequest('gsoc:read', null, 'gsoc:value', CONFIG.READ_TIMEOUT).then(function (d) { log('readFromConfirm: значение получено (' + brief(d.value, 120) + ')'); return d.value || null; }); } function unescapeDeep(text) { var out = String(text || ''); for (var i = 0; i < 6; i++) { var next = out.replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\'/g, "'"); if (next === out) break; out = next; } return out; } function parseRecordJson(text) { var marker = "'orders':"; var idx = text.indexOf(marker); if (idx === -1) return null; var start = text.lastIndexOf('{', idx); if (start === -1) return null; var depth = 0; var inStr = false; for (var i = start; i < text.length; i++) { var ch = text.charAt(i); if (inStr) { if (ch === '\\') { i++; continue; } if (ch === "'") inStr = false; continue; } if (ch === "'") { inStr = true; continue; } if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { try { return JSON.parse(text.slice(start, i + 1).replace(/'/g, '"')); } catch (e) { return null; } } } } return null; } function decodeStoredValue(rawValue) { var raw = String(rawValue || '').trim(); if (raw.indexOf('\\"') === 0 && raw.lastIndexOf('\\"') === raw.length - 2) raw = raw.slice(2, -2); return parseRecordJson(unescapeDeep(raw)); } function recordsForDeals(data, dealIds) { var orders = (data && data.orders) || {}; var found = []; dealIds.forEach(function (id) { if (orders[id]) found.push({ dealId: id, record: orders[id] }); }); return found; } function formatDate(iso) { if (!iso) return ''; var d = new Date(iso); if (isNaN(d.getTime())) return iso; var pad = function (n) { return (n < 10 ? '0' : '') + n; }; return pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.' + d.getFullYear() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } var pay = { ready: false, saving: false, saved: false, locked: false, dealId: null, offerIds: [], userId: null, setName: null, settings: null, items: [], payRoot: null, blockEl: null }; function isPayPage() { if (CONFIG.PAY_PATH.test(location.pathname)) return true; var signals = 0; if (document.querySelector('input[name="deal_id"]')) signals++; if (document.querySelector('.gc-payment-method-button')) signals++; if (document.querySelector('.xdget-payform')) signals++; log('isPayPage: путь не похож на оплату, признаков разметки=' + signals + '/3 (нужно ≥2)'); return signals >= 2; } function findPayRoot() { for (var i = 0; i < CONFIG.PAY_ROOTS.length; i++) { var el = document.querySelector(CONFIG.PAY_ROOTS[i]); if (el) { log('зона оплаты найдена по селектору "' + CONFIG.PAY_ROOTS[i] + '" → ' + describeEl(el)); return el; } } warn('зона оплаты не найдена. Проверенные селекторы: ' + CONFIG.PAY_ROOTS.join(' | ')); return null; } function payContext() { var m = /\/id\/(\d+)/.exec(location.pathname); var input = document.querySelector('input[name="deal_id"]'); var dealId = m ? m[1] : (input ? String(input.value) : ''); var offers = []; var nodes = document.querySelectorAll('[data-offer-id]'); for (var i = 0; i < nodes.length; i++) { var v = String(nodes[i].getAttribute('data-offer-id') || '').trim(); if (v && offers.indexOf(v) === -1) offers.push(v); } log('payContext: заказ=' + (dealId || '—') + ' (источник: ' + (m ? 'URL /id/<число>' : (input ? 'input[name=deal_id]' : 'не найден')) + '), предложений по [data-offer-id]=' + nodes.length + ' → [' + offers.join(', ') + ']'); if (!offers.length) { var lis = document.querySelectorAll('li[class*="offer-position-"]'); for (var j = 0; j < lis.length; j++) { var id = (String(lis[j].className).match(/offer-position-(\d+)/) || [])[1]; if (id && offers.indexOf(id) === -1) offers.push(id); } log('payContext: резервный поиск по li.offer-position-* — найдено ' + lis.length + ' элементов → [' + offers.join(', ') + ']'); } return { dealId: dealId, offerIds: offers, userId: currentUserId() }; } function readSettingsFromDom() { var marker = document.querySelector(CONFIG.MARKER); if (!marker) { log('блок настроек (' + CONFIG.MARKER + ') на этой странице не найден'); return null; } var raw = marker.getAttribute('data-gsoc'); if (!raw) { warn('блок настроек найден, но у него нет атрибута data-gsoc (пустой блок?)'); return null; } log('блок настроек найден: data-gsoc ' + raw.length + ' симв., элемент ' + describeEl(marker)); try { var parsed = JSON.parse(b64decode(raw)); log('настройки из блока: ' + describeSettings(parsed)); return parsed; } catch (e) { warn('data-gsoc не разобрался (base64/JSON): ' + (e && e.message)); return null; } } /* Краткая сводка настроек для консоли: версия, наборы, обязательные пункты. */ function describeSettings(settings) { if (!settings || typeof settings !== 'object') return 'нет настроек'; var offers = settings.offers || {}; var keys = Object.keys(offers); var def = (settings.default && settings.default.items) || []; var visible = def.filter(function (it) { return it.active !== false; }).length; var required = def.filter(function (it) { return it.active !== false && it.required !== false; }).length; return 'updatedAt=' + (settings.updatedAt || '—') + ', title="' + brief(settings.title, 40) + '"' + ', наборов=' + keys.length + ' [' + keys.join(', ') + ']' + ', пунктов по умолчанию=' + def.length + ' (видимых ' + visible + ', обязательных ' + required + ')'; } function cacheSettings(settings) { try { localStorage.setItem(CONFIG.KEY_SETTINGS, JSON.stringify({ at: Date.now(), settings: settings })); } catch (e) {} } function cachedSettings() { try { var parsed = JSON.parse(localStorage.getItem(CONFIG.KEY_SETTINGS) || 'null'); if (!parsed || !parsed.at || !parsed.settings) return null; var age = Date.now() - parsed.at; if (age > CONFIG.SETTINGS_TTL) { log('кэш настроек устарел (' + Math.round(age / 1000) + ' с > ' + (CONFIG.SETTINGS_TTL / 1000) + ' с)'); return null; } return parsed.settings; } catch (e) { return null; } } function loadSettings() { var fromDom = readSettingsFromDom(); if (fromDom) { pay.settings = fromDom; cacheSettings(fromDom); log('настройки взяты из блока на этой странице'); return Promise.resolve(fromDom); } var cached = cachedSettings(); log('настройки: читаю страницу-хранилище ' + CONFIG.STORE_PATH + (cached ? ' (кэш localStorage есть — ' + describeSettings(cached) + ')' : ' (кэша localStorage нет)')); var t0 = Date.now(); return fetch(CONFIG.STORE_PATH + '?gsoc=' + Date.now(), { credentials: 'include' }) .then(function (r) { log('страница-хранилище: ответ ' + r.status + ' за ' + (Date.now() - t0) + ' мс'); return r.text(); }) .then(function (html) { log('страница-хранилище: HTML ' + String(html || '').length + ' симв.'); var m = /data-gsoc="([A-Za-z0-9+/=]+)"/.exec(html || ''); if (!m) throw new Error('в ' + CONFIG.STORE_PATH + ' не найден блок настроек'); var settings = JSON.parse(b64decode(m[1])); pay.settings = settings; cacheSettings(settings); log('настройки прочитаны со страницы-хранилища: ' + describeSettings(settings)); return settings; })['catch'](function (error) { if (cached) { warn('нет доступа к настройкам, беру кэш (' + (error.message || error) + ')'); pay.settings = cached; return cached; } throw error; }); } /* Поле «ID предложения» в админке допускает список: «3929853, 3928543». Разбираем значение по запятым, точкам с запятой и пробелам/переносам. */ function offerIdList(value) { return String(value == null ? '' : value) .split(/[\s,;]+/) .filter(function (v) { return !!v; }); } /* Возвращает ID набора, который есть в предложениях заказа (или null). */ function matchOfferSet(set, offerIds) { var ids = offerIdList(set && set.id); for (var i = 0; i < ids.length; i++) { if (offerIds.indexOf(ids[i]) !== -1) return ids[i]; } return null; } function pickItems(settings, offerIds) { var wanted = (Array.isArray(offerIds) ? offerIds : offerIdList(offerIds)).map(String); var offers = settings.offers || {}; var keys = Object.keys(offers); log('pickItems: предложения заказа=[' + wanted.join(', ') + '], наборов в настройках=' + keys.length + ' [' + keys.map(function (k) { return k + '→' + (offers[k] && offers[k].id); }).join(' | ') + ']'); for (var i = 0; i < keys.length; i++) { var set = offers[keys[i]] || {}; var hit = matchOfferSet(set, wanted); if (!hit) continue; var ids = offerIdList(set.id); log('набор предложения ' + hit + ' (' + (set.name || '') + ')' + (ids.length > 1 ? ' [совпал из списка: ' + ids.join(', ') + ']' : '')); return (set.items || []).filter(function (it) { return it.active !== false; }); } log('набор по умолчанию (совпадений с предложениями заказа нет)'); var def = settings.default && settings.default.items ? settings.default.items : []; return def.filter(function (it) { return it.active !== false; }); } function itemMarkup(item) { var html = esc(item.prefix || ''); if (item.linkText) { html += (html ? ' ' : '') + '' + esc(item.linkText) + ''; } if (item.suffix) html += ' ' + esc(item.suffix); return html; } function payStyleCss() { var def = CONFIG.DEFAULT_STYLE; var st = (pay.settings && pay.settings.style) || {}; return 'background:' + (st.bg || def.bg) + ';color:' + (st.text || def.text) + ';--gsoc-link:' + (st.link || def.link) + ';border-radius:' + (st.radius == null ? def.radius : st.radius) + 'px' + ';border:' + (st.borderWidth == null ? def.borderWidth : st.borderWidth) + 'px solid ' + (st.borderColor || def.borderColor) + ';font-size:' + (st.fontSize || def.fontSize) + 'px'; } function payBlockHtml() { var items = pay.items.map(function (item) { return '
' + itemMarkup(item) + '
'; }).join(''); return '
' + '
' + esc(CONFIG.SNAPSHOT_TITLE) + '
' + items + '
'; } function renderPayBlock() { var itemsHtml = pay.items.map(function (item) { return [ '' ].join(''); }).join(''); var wrap = document.createElement('div'); wrap.className = 'gsoc-pay'; wrap.innerHTML = [ '
', '
', esc((pay.settings && pay.settings.title) || 'Подтвердите согласия'), '
', itemsHtml, '
', '
Чтобы перейти к оплате, отметьте все обязательные согласия.
' ].join(''); pay.blockEl = wrap; log('renderPayBlock: вставляю блок перед ' + describeEl(pay.payRoot) + ' (пунктов ' + pay.items.length + ', обязательных ' + pay.items.filter(function (it) { return it.required !== false; }).length + ', родитель ' + describeEl(pay.payRoot.parentNode) + ')'); pay.payRoot.parentNode.insertBefore(wrap, pay.payRoot); wrap.addEventListener('change', onPayChange); log('блок согласий вставлен перед зоной оплаты'); } function setPayStatus(text, kind) { if (!pay.blockEl) return; var el = pay.blockEl.querySelector('[data-pay-status]'); if (!el) return; el.textContent = text || ''; el.className = 'gsoc-pay-status' + (kind ? ' ' + kind : ''); } function payButtons() { if (!pay.payRoot) return []; return [].slice.call(pay.payRoot.querySelectorAll('button, input[type=submit], a.btn, ' + CONFIG.PAY_BUTTONS)); } function applyLock(locked) { if (!pay.payRoot) return; if (locked) { pay.payRoot.classList.add(CONFIG.LOCK_CLASS); pay.payRoot.classList.add(CONFIG.HIDE_CLASS); payButtons().forEach(function (b) { if (!b.getAttribute('data-gsoc-disabled')) { b.setAttribute('data-gsoc-disabled', b.disabled ? '1' : '0'); b.setAttribute('disabled', 'disabled'); } }); } else { pay.payRoot.classList.remove(CONFIG.LOCK_CLASS); pay.payRoot.classList.remove(CONFIG.HIDE_CLASS); payButtons().forEach(function (b) { if (b.getAttribute('data-gsoc-disabled') === '0') b.removeAttribute('disabled'); b.removeAttribute('data-gsoc-disabled'); }); } pay.locked = !!locked; log('оплата ' + (locked ? 'заблокирована' : 'разблокирована') + ' (кнопок ' + payButtons().length + ')'); log('applyLock(' + locked + '): ' + describeEl(pay.payRoot) + ', классов ' + CONFIG.LOCK_CLASS + '=' + pay.payRoot.classList.contains(CONFIG.LOCK_CLASS) + ', ' + CONFIG.HIDE_CLASS + '=' + pay.payRoot.classList.contains(CONFIG.HIDE_CLASS) + ', кнопок с data-gsoc-disabled=' + pay.payRoot.querySelectorAll('[data-gsoc-disabled]').length); } /* Название выбранного набора — для логов и диагностики (в само поведение не влияет). */ function pickedSetName(settings, offerIds) { var wanted = (Array.isArray(offerIds) ? offerIds : offerIdList(offerIds)).map(String); var offers = (settings && settings.offers) || {}; var keys = Object.keys(offers); for (var i = 0; i < keys.length; i++) { var set = offers[keys[i]] || {}; var hit = matchOfferSet(set, wanted); if (hit) return 'набор предложения «' + (set.name || keys[i]) + '» (' + keys[i] + ', совпало ' + hit + ')'; } return 'набор по умолчанию'; } /* Пункты, которые нужно отметить, чтобы оплата открылась. При LOCK_ONLY_REQUIRED=false обязательным считается каждый видимый пункт. */ function requiredItems() { if (!CONFIG.LOCK_ONLY_REQUIRED) return pay.items.slice(); return pay.items.filter(function (it) { return it.required !== false; }); } function isRequired(item) { if (!CONFIG.LOCK_ONLY_REQUIRED) return true; return item.required !== false; } /* Состояние чекбоксов блока — строкой для логов. «*» — обязательный пункт. */ function checkedState() { return pay.items.map(function (it) { var input = pay.blockEl ? pay.blockEl.querySelector('input[data-item="' + it.id + '"]') : null; return (isRequired(it) ? '*' : '') + it.id + '=' + (input ? (input.checked ? 'on' : 'off') : 'нет в DOM'); }).join(', '); } /* ID обязательных пунктов, которые ещё не отмечены. */ function missingRequired() { return requiredItems().filter(function (it) { var input = pay.blockEl ? pay.blockEl.querySelector('input[data-item="' + it.id + '"]') : null; return !input || !input.checked; }).map(function (it) { return it.id; }); } function requiredChecked() { return missingRequired().length === 0; } function updateLock() { if (!pay.payRoot) return; var locked = !requiredChecked(); if (locked === pay.locked) { log('updateLock: состояние не изменилось (locked=' + locked + '), пропускаю'); return; } log('updateLock: ' + (locked ? 'блокирую' : 'разблокирую') + ' оплату, не отмечено обязательных: [' + missingRequired().join(', ') + ']'); applyLock(locked); } function buildPayPayload() { var s = pay.settings || {}; return { dealId: String(pay.dealId || ''), userId: String(pay.userId || ''), offerIds: pay.offerIds.slice(), savedAt: new Date().toISOString(), settingsVersion: s.updatedAt || '', items: [{ html: b64encode(payBlockHtml()) }] }; } function onPayChange() { if (!pay.ready) { log('onPayChange: блок ещё не готов — пропускаю'); return; } log('onPayChange: ' + checkedState() + ' | не отмечено обязательных: [' + missingRequired().join(', ') + ']'); updateLock(); if (!requiredChecked()) { log('согласия сняты — оплата снова заблокирована'); return; } if (pay.saved || pay.saving) { log('согласия отмечены заново — повторная запись не нужна (saved=' + pay.saved + ', saving=' + pay.saving + ')'); return; } pay.saving = true; var t0 = Date.now(); log('все обязательные отмечены, сохраняю'); saveToConfirm(buildPayPayload()).then(function (result) { pay.saving = false; pay.saved = true; updateLock(); var ok = result && result.ok; setPayStatus(ok ? '' : 'Не удалось сохранить подтверждение. Оплата доступна.', ok ? '' : 'error'); log('запись завершена, ok=' + !!ok + ', за ' + (Date.now() - t0) + ' мс'); })['catch'](function (error) { pay.saving = false; pay.saved = true; updateLock(); setPayStatus('Не удалось сохранить подтверждение. Оплата доступна.', 'error'); err('ошибка сохранения (fail-open) за ' + (Date.now() - t0) + ' мс:', error); }); } function startPayMode() { pay.payRoot = findPayRoot(); if (!pay.payRoot) { warn('зона оплаты не найдена — оплату не трогаю'); return; } var ctx = payContext(); pay.dealId = ctx.dealId; pay.offerIds = ctx.offerIds; pay.userId = ctx.userId; log('страница оплаты: заказ=' + pay.dealId + ', предложения=' + (pay.offerIds.join(',') || '—') + ', пользователь=' + pay.userId); log('страница оплаты: пользователь по данным страницы=' + safe(function () { return currentUserId(); }, 'гость') + ', admin=' + isAdminViewer() + ', кнопок в зоне оплаты=' + payButtons().length + ', iframe подтверждений ' + (frame ? 'уже создан' : 'ещё не создан')); if (!pay.dealId) { warn('номер заказа не определён (нет ни /id/<число> в URL, ни input[name=deal_id]) — выхожу'); return; } var t0 = Date.now(); loadSettings().then(function (settings) { pay.items = pickItems(settings, pay.offerIds); pay.setName = pickedSetName(settings, pay.offerIds); if (!pay.items.length) { warn('в наборе нет видимых чекбоксов — оплату не блокирую'); return; } var need = requiredItems(); if (!need.length) { /* Блокировать нечего: в наборе нет ни одного пункта с тумблером «обязательный». Блокировку не включаем вовсе — иначе оплата «открывалась бы» от любой галочки. */ log('выбран ' + pay.setName + ': пунктов ' + pay.items.length + ', обязательных 0'); warn('в ' + pay.setName + ' нет ни одного обязательного пункта — оплата НЕ блокируется. ' + 'Включите тумблер «обязательный» у нужных пунктов на странице ' + CONFIG.STORE_PATH + ' ' + 'или задайте `window.GSOC_REQUIRE_ALL = true;` перед этим скриптом, чтобы требовать отметку ВСЕХ пунктов.'); renderPayBlock(); pay.ready = true; watchPay(); log('готово: пунктов ' + pay.items.length + ', обязательных 0 (оплата не блокируется), за ' + (Date.now() - t0) + ' мс; ' + describeSettings(settings)); return; } renderPayBlock(); applyLock(true); pay.ready = true; watchPay(); log('выбран ' + pay.setName + ': пунктов ' + pay.items.length + ', обязательных ' + need.length + (CONFIG.LOCK_ONLY_REQUIRED ? '' : ' (режим «требовать все пункты»)')); log('готово: пунктов ' + pay.items.length + ', обязательных ' + need.length + ', за ' + (Date.now() - t0) + ' мс; ' + describeSettings(settings)); })['catch'](function (error) { warn('fail-open: ' + (error.message || error)); }); } function watchPay() { if (!window.MutationObserver || watchPay._started) return; watchPay._started = true; new MutationObserver(function () { if (!pay.ready || !pay.payRoot) return; if (pay.locked) { if (!pay.payRoot.classList.contains(CONFIG.HIDE_CLASS)) applyLock(true); else payButtons().forEach(function (b) { if (!b.hasAttribute('data-gsoc-disabled')) { b.setAttribute('disabled', 'disabled'); warn('страница вернула кнопку оплаты — снова блокирую: ' + describeEl(b)); } }); } if (pay.blockEl && !document.body.contains(pay.blockEl)) { warn('блок согласий удалён страницей — вставляю заново'); pay.blockEl = null; if (document.body.contains(pay.payRoot)) renderPayBlock(); } }).observe(document.body, { childList: true, subtree: true }); } var order = { mode: null, userId: null, dealIds: [], data: null, rendered: false, isSelf: false, isAdmin: false }; function isOrderCardPage() { return location.pathname.indexOf(CONFIG.ORDER_CARD_PATH) !== -1; } function orderBuyerId() { var link = document.querySelector('.key-value-table a.user-profile-link[data-user-id]'); if (link) { log('orderBuyerId: из ссылки профиля (' + describeEl(link) + ') = ' + link.getAttribute('data-user-id')); return link.getAttribute('data-user-id'); } var panel = document.getElementById('userFormAdditionalFields'); if (panel) { var m = /[?&]id=(\d+)/.exec(panel.getAttribute('data-load-url') || ''); if (m) { log('orderBuyerId: из data-load-url панели доп. полей = ' + m[1]); return m[1]; } } var any = document.querySelector('a[href*="/user/control/user/update/id/"]'); if (any) { var m2 = /id\/(\d+)/.exec(any.getAttribute('href') || ''); if (m2 && m2[1] !== '-1' && m2[1] !== '-2') { log('orderBuyerId: из ссылки на карточку пользователя = ' + m2[1]); return m2[1]; } } log('orderBuyerId: покупатель по разметке не определён (нет ссылки профиля, панели доп. полей и ссылки на пользователя)'); return null; } function orderDealIds() { var ids = []; var add = function (value) { var v = String(value || '').trim(); if (v && ids.indexOf(v) === -1) ids.push(v); }; var panels = [].slice.call(document.querySelectorAll('.panel')); var related = panels.filter(function (p) { var h = p.querySelector('.panel-heading'); return h && /Связанные заказы/i.test(h.textContent || ''); })[0]; log('orderDealIds: панелей .panel=' + panels.length + ', панель «Связанные заказы»=' + (related ? 'есть' : 'нет')); if (related) { var relatedLinks = related.querySelectorAll('a[href*="/deal/"]'); [].slice.call(relatedLinks).forEach(function (a) { var m = /\/deal\/(?:update\/)?id\/(\d+)/.exec(a.getAttribute('href') || ''); if (m) add(m[1]); }); log('orderDealIds: ссылок на заказы в панели=' + relatedLinks.length + ' → [' + ids.join(', ') + ']'); } if (!ids.length) { var tableLinks = document.querySelectorAll('.key-value-table a[href*="deal"]'); [].slice.call(tableLinks).forEach(function (a) { var m = /\/deal\/(?:update\/)?id\/(\d+)/.exec(a.getAttribute('href') || ''); if (m) add(m[1]); }); log('orderDealIds: резервный поиск по таблице карточки — ссылок=' + tableLinks.length + ' → [' + ids.join(', ') + ']'); } if (!ids.length) { var dataEls = document.querySelectorAll('[data-deal-id]'); [].slice.call(dataEls).forEach(function (el) { add(el.getAttribute('data-deal-id')); }); log('orderDealIds: резервный поиск по [data-deal-id] — элементов=' + dataEls.length + ' → [' + ids.join(', ') + ']'); } if (!ids.length) { var inputs = document.querySelectorAll('input[name="deal_id"], input[name="dealId"]'); [].slice.call(inputs).forEach(function (el) { add(el.value); }); log('orderDealIds: резервный поиск по input[deal_id/dealId] — полей=' + inputs.length + ' → [' + ids.join(', ') + ']'); } return ids; } function loadBuyerField(userId) { var url = CONFIG.ADD_FIELDS_URL + userId; var t0 = Date.now(); log('loadBuyerField: запрашиваю доп. поля покупателя ' + url + ' (админ читает чужой ДП)'); return fetch(url, { credentials: 'include' }) .then(function (r) { log('loadBuyerField: ответ ' + r.status + ' за ' + (Date.now() - t0) + ' мс'); return r.text(); }) .then(function (html) { log('ответ доп. полей покупателя: ' + html.length + ' байт'); var data = decodeStoredValue(html) || parseRecordJson(unescapeDeep(html)); if (!data) throw new Error('в ответе нет данных поля ' + CONFIG.FIELD); log('loadBuyerField: разобрано, заказов в поле=' + Object.keys(data.orders || {}).length); return data; }); } function orderRecordHtml(record) { var items = (record && record.items) || []; var html = items.map(function (it) { try { return b64decode(it.html); } catch (e) { return ''; } }).join(''); if (!html) return ''; var tmp = document.createElement('div'); tmp.innerHTML = html; [].slice.call(tmp.querySelectorAll('[style]')).forEach(function (el) { el.removeAttribute('style'); }); return [ '
', tmp.innerHTML, '
', '
Подтверждено: ', formatDate(record.savedAt), '
' ].join(''); } function renderOrderRow() { var table = document.querySelector('.key-value-table'); if (!table || !table.tBodies.length) { warn('таблица карточки заказа (.key-value-table) не найдена'); return false; } if (table.querySelector('tr[' + CONFIG.ORDER_BLOCK_ATTR + ']')) { log('строка «Согласия» уже есть в таблице — не дублирую'); order.rendered = true; return true; } var found = recordsForDeals(order.data, order.dealIds); log('renderOrderRow: заказов в карточке=' + order.dealIds.length + ' [' + order.dealIds.join(', ') + ']' + ', записей в ДП=' + Object.keys((order.data && order.data.orders) || {}).length + ' [' + Object.keys((order.data && order.data.orders) || {}).join(', ') + ']' + ', совпало=' + found.length); var body = found.length ? found.map(function (f) { return orderRecordHtml(f.record); }).join('') : '
Нет информации о подтверждённых согласиях по этому заказу.
'; var row = document.createElement('tr'); row.setAttribute(CONFIG.ORDER_BLOCK_ATTR, '1'); var key = document.createElement('td'); key.className = 'key'; key.textContent = 'Согласия'; var value = document.createElement('td'); value.className = 'value'; value.innerHTML = body; row.appendChild(key); row.appendChild(value); table.tBodies[0].appendChild(row); order.rendered = true; log('строка «Согласия» добавлена (записей ' + found.length + ')'); return true; } function watchOrder() { if (!window.MutationObserver || watchOrder._started) return; watchOrder._started = true; new MutationObserver(function () { if (!order.rendered) return; var table = document.querySelector('.key-value-table'); if (table && !table.querySelector('tr[' + CONFIG.ORDER_BLOCK_ATTR + ']')) renderOrderRow(); }).observe(document.body, { childList: true, subtree: true }); } function startOrderMode() { var selfId = currentUserId(); var buyerId = orderBuyerId(); order.userId = buyerId || selfId || null; order.dealIds = orderDealIds(); order.isSelf = !!selfId && String(order.userId) === String(selfId); order.isAdmin = isAdminViewer(); order.mode = order.isSelf ? 'self' : (order.isAdmin ? 'admin' : 'guest'); log('карточка заказа: смотрит=' + selfId + ' (' + order.mode + '), покупатель=' + order.userId + ', заказы=' + (order.dealIds.join(',') || '—')); log('карточка заказа: покупатель взят ' + (buyerId ? 'из разметки карточки' : 'из своей сессии (покупатель в разметке не найден)') + ', admin=' + order.isAdmin + ', заказов в карточке=' + order.dealIds.length); injectStyles(); var t0 = Date.now(); var loader; if (order.isSelf || !order.isAdmin) { if (!order.isSelf) warn('чужие согласия недоступны: нет прав на чтение ДП другого пользователя'); log('карточка заказа: читаю ДП через ' + CONFIG.CONFIRM_PAGE + ' (' + (order.isSelf ? 'свой заказ' : 'режим гостя') + ')'); loader = readFromConfirm; } else { log('карточка заказа: читаю ДП покупателя как админ через ' + CONFIG.ADD_FIELDS_URL + ''); loader = function () { return loadBuyerField(order.userId); }; } loader() .then(function (data) { order.data = data; log('карточка заказа: данные получены за ' + (Date.now() - t0) + ' мс'); renderOrderRow(); watchOrder(); })['catch'](function (error) { warn('не удалось прочитать ДП за ' + (Date.now() - t0) + ' мс: ' + (error.message || error)); order.data = null; renderOrderRow(); watchOrder(); }); } function boot() { log('запуск: ' + envInfo()); log('признаки страницы: ' + pageSignals()); injectStyles(); if (isPayPage()) { log('режим: страница оплаты'); startPayMode(); return; } if (isOrderCardPage()) { log('режим: карточка заказа'); startOrderMode(); return; } log('подходящий режим не найден — ничего не делаю (не страница оплаты и не карточка заказа)'); } window.GSOC = { version: VERSION, logLevel: function () { return LOG_LEVEL; }, setLog: setLogLevel, pay: pay, order: order, config: CONFIG, offerIdList: offerIdList, pickItems: pickItems, save: function () { return saveToConfirm(buildPayPayload()); }, snapshot: payBlockHtml, readOwn: readFromConfirm, frameState: function () { return frameState(); }, /* Полная сводка состояния — печатается в консоль и возвращается объектом. Быстрый способ понять, почему что-то не сработало: GSOC.diag() в консоли. */ diag: function () { return { version: VERSION, logLevel: LOG_LEVEL, env: envInfo(), page: isPayPage() ? 'pay' : (isOrderCardPage() ? 'order' : 'other'), signals: pageSignals(), settings: describeSettings(pay.settings), pay: { dealId: pay.dealId, offerIds: pay.offerIds, items: pay.items.length, setName: pay.setName, requiredItems: requiredItems().length, lockOnlyRequired: CONFIG.LOCK_ONLY_REQUIRED, locked: pay.locked, saved: pay.saved, saving: pay.saving, ready: pay.ready, payRoot: describeEl(pay.payRoot), checked: checkedState(), missingRequired: missingRequired() }, frame: frameState(), order: { mode: order.mode, userId: order.userId, dealIds: order.dealIds, rendered: order.rendered, hasData: !!order.data }, settingsVersion: pay.settings ? pay.settings.updatedAt : null }; }, debug: function () { var info = window.GSOC.diag(); log('GSOC.debug: ' + brief(info, 600)); return info; } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();