/* ===================================================================================== КЛИЕНТСКАЯ СБОРКА ШКОЛЫ anarbaeva.pro (отдельная копия, оригинал — src/gs-ofert.js). ------------------------------------------------------------------------------------- Отличие от оригинала (версия 11 → 12 → 13 → 15): поддержка модификаторов (апселлов) GetCourse и «оплаты в оверлее» — виджета оплаты GetCourse на странице школы (.gcpay-widget). В оверлее блок согласий ставится сразу после блока заказа, а пока он не подтверждён, закрываются и основные, и альтернативные способы оплаты. Если покупатель добавил к заказу модификатор, к набору согласий добавляются пункты этого модификатора (набор «модификатор → пункты» настраивается в клиентской админке clients/anarbaeva/gs-ofert-checkbox.js, секция modifiers в настройках). Модификаторы дополняют базовый набор (набор предложения или набор по умолчанию), не заменяют его: 1 добавленный модификатор = +1 набор пунктов, 2 = +2 и т.д. Разметка «модификатор добавлен» проверена на бою (anarbaeva.pro, 24.09.2026): строка [id^="mod"] внутри ol.deal-positions с кнопкой [name="delete_modifier"]; «доступный» модификатор — та же строка с кнопкой [name="add_modifier"]. Секцию modifiers оригинальный src/gs-ofert.js просто игнорирует, поэтому настройки совместимы в обе стороны. ===================================================================================== */ (function () { 'use strict'; /* Версия сборки. Поднимать при каждой выкладке: в консоли видно, какая именно копия скрипта работает (README → «Разработка и проверка»). */ var VERSION = 15; 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', /* Модификаторы (апселлы) заказа. Строка модификатора —
внутри ol.deal-positions: «добавлен» — в строке есть кнопка [name="delete_modifier"], «доступен» — [name="add_modifier"] (проверено на anarbaeva.pro 24.09.2026). */ MODIFIER_ROW_SELECTOR: 'ol.deal-positions [id^="mod"]', MOD_REMOVE_BTN: '[name="delete_modifier"]', MOD_ADD_BTN: '[name="add_modifier"]', /* Префикс id пунктов модификатора: наборы школы переиспользуют одинаковые id (например cb_oferta), без префикса пункты конфликтовали бы в DOM. */ MODIFIER_ITEM_PREFIX: 'mod', /* Оплата в оверлее: виджет оплаты GetCourse на странице школы (Vue-приложение в #app). Данные заказа берём из его сторов Pinia, согласия вставляем в виджет. */ PAY_WIDGET: '.gcpay-widget', WIDGET_ORDER: '.gcpay-widget-order', WIDGET_METHODS: '.gcpay-widget-payment-methods', WIDGET_ALT_METHODS: '.gcpay-widget-payment-alternative-methods', WIDGET_BUTTONS: '.gcpay-widget-payment-method-btn', WIDGET_LOCK_CLASS: 'gsoc-overlay-locked', /* Сколько раз разрешено переносить блок согласий внутри виджета (защита от бесконечных перестановок на перерисовках Vue). */ WIDGET_PLACE_LIMIT: 5, 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, /* Повторы записи согласий. Первая попытка идёт сразу после отметки галочек, дальше — по этим задержкам (мс). Нужно потому, что у гостевого покупателя запись проходит только после того, как GetCourse «привяжет» сессию к пользователю (это происходит при отправке формы покупателя). Раньше при неудаче флага «записано» уже не снимался, повторов не было — и в ДП покупателя оставалось пусто. */ RETRY_DELAYS: [1500, 4000, 10000, 20000, 30000, 60000], RETRY_MAX_ATTEMPTS: 10, AUTH_POLL_MS: 1000, AUTH_POLL_LIMIT: 300, /* Очередь неотправленных согласий (localStorage): если запись не удалась за время визита, согласия до-записываются при следующем открытии страницы оплаты/карточки этого заказа. */ KEY_QUEUE: 'gsoc_pending', QUEUE_TTL: 7 * 24 * 60 * 60 * 1000, QUEUE_LIMIT: 20, /* До-запись проверяем при загрузке любой страницы, но не чаще раза в QUEUE_THROTTLE_MS и не больше QUEUE_DELIVER_LIMIT записей за один заход (чтобы не долбить форму). */ KEY_QUEUE_THROTTLE: 'gsoc_queue_at', QUEUE_THROTTLE_MS: 5 * 60 * 1000, QUEUE_DELIVER_LIMIT: 3, /* Контакты текущего пользователя (для проверки «того ли пользователя» при до-записи), когда в записи нет id покупателя. Кэшируем в localStorage на сутки. */ KEY_MY_CONTACTS: 'gsoc_my_contacts', CONTACTS_TTL: 24 * 60 * 60 * 1000, MY_PROFILE_PATH: '/user/my/profile', /* Канал «почта/телефон» в форме подтверждений (позволяет писать ДП от гостевой сессии, но для незнакомого адреса GetCourse может создать нового пользователя) — по умолчанию выключен. Включение: `window.GSOC_SEND_IDENTITY = true;` перед этим скриптом. */ SEND_IDENTITY: !!(typeof window !== 'undefined' && window.GSOC_SEND_IDENTITY === true), 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); } /* --- Режим работы расширения (задаётся в админке, поле mode в настройках) ------------------ on — согласия видят все покупатели (значение по умолчанию); admin — блок и блокировка только у администраторов школы (тест на боевом); off — скрипт на странице оплаты ничего не делает. */ var MODES = { on: 'Включено', admin: 'Только администрация (тест)', off: 'Выключено' }; function settingsMode(settings) { var m = String((settings && settings.mode) || 'on').toLowerCase(); return MODES.hasOwnProperty(m) ? m : 'on'; } /* Можно ли работать в этом режиме на этой странице (и почему нельзя). */ function modeAllowsWork(settings) { var mode = settingsMode(settings); if (mode === 'off') { return { mode: mode, work: false, reason: 'режим настроек «Выключено» — на странице оплаты ничего не делаю' }; } if (mode === 'admin' && !isAdminViewer()) { return { mode: mode, work: false, reason: 'режим настроек «Только администрация» — покупателю блок согласий не показываю' }; } return { mode: mode, work: true, reason: 'режим настроек: ' + MODES[mode] + (mode === 'admin' ? ' (тестовый)' : '') }; } /* Описание 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 payCssLines() { return [ '.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-note{margin:0 0 6px;font-size:inherit;line-height:1.45}', '.gsoc-order-empty{font-size:13px;color:#8b8b9e}', /* Оплата в оверлее: блок согласий внутри виджета GetCourse */ '.gcpay-widget .gsoc-pay{margin:0 0 14px}', '.gcpay-widget .gsoc-pay-body{padding:14px 16px}', /* Пока согласия не отмечены, закрываем и основные, и альтернативные способы оплаты. */ '.gcpay-widget.' + CONFIG.WIDGET_LOCK_CLASS + ' ' + CONFIG.WIDGET_METHODS + ',' + '.gcpay-widget.' + CONFIG.WIDGET_LOCK_CLASS + ' ' + CONFIG.WIDGET_ALT_METHODS + '{display:none !important}' ]; } function injectStyles() { var existing = document.getElementById(CONFIG.STYLES_ID); if (existing) { /* Стиль мог быть добавлен другой сборкой ядра — доводим набор правил до текущего. */ var have = existing.textContent || ''; var missing = payCssLines().filter(function (line) { return have.indexOf(line) === -1; }); if (!missing.length) return; existing.textContent = have + missing.join(''); log('стили блока согласий дополнены: ' + missing.length + ' правил'); return; } var css = payCssLines().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: [], /* Модификаторы (апселлы), добавленные в заказ: ID, подпись состава (для слежения за изменениями без перезагрузки) и ID, для которых в настройках нет набора. */ modifierIds: [], modifierSignature: '', unknownModifierIds: [], /* Режим ядра: null — страница оплаты, 'overlay' — виджет оплаты в оверлее. */ kind: null, /* Сколько раз блок согласий переносился внутри виджета (см. placeOverlayBlock). */ overlayMoves: 0, /* Сколько раз пересобирали блок в оверлее (данные виджета приходят частями). */ overlayRebuilds: 0, userId: null, setName: null, settings: null, items: [], payRoot: null, blockEl: null, /* Диагностика и повторы записи согласий (см. attemptSave/scheduleRetry). */ attempts: 0, lastError: null, retryTimer: null, authTimer: null, authTicks: 0, identity: null, /* Для наборов без обязательных пунктов: какие отметки были в последней успешной записи — чтобы обновлять запись, когда покупатель всё-таки отметил пункты. */ lastSavedChecked: null, /* buyerId и источник данных покупателя (для diag). */ buyerId: null, buyerEmail: null, buyerPhone: null, buyerSource: null, /* Режим работы из настроек (on | admin | off) — см. modeAllowsWork(). */ mode: null, /* До-запись очереди: когда была и чем закончилась (для diag). */ queueDelivery: 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; var modKeys = Object.keys(settings.modifiers || {}); return 'updatedAt=' + (settings.updatedAt || '—') + ', title="' + brief(settings.title, 40) + '"' + ', наборов=' + keys.length + ' [' + keys.join(', ') + ']' + ', наборов модификаторов=' + modKeys.length + ' [' + modKeys.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; } /* --- Модификаторы (апселлы) заказа ------------------------------------------------ */ /* ID модификаторов, ДОБАВЛЕННЫХ в заказ. Разметка (проверено на anarbaeva.pro):
← модификатор добавлен ← доступен, не добавлен В добавленной строке поле modifier_id встречается дважды (форма «убрать» и пустая форма), поэтому собираем по строкам, а не по полям формы. */ function collectModifierIds() { var rows = document.querySelectorAll(CONFIG.MODIFIER_ROW_SELECTOR); var out = []; for (var i = 0; i < rows.length; i++) { var row = rows[i]; var input = row.querySelector('input[name="modifier_id"]'); var id = input ? String(input.value || '').trim() : String(row.id || '').replace(/^mod/, ''); if (!/^\d+$/.test(id)) { warn('строка модификатора ' + describeEl(row) + ': не удалось определить ID модификатора — пропускаю'); continue; } if (row.querySelector(CONFIG.MOD_REMOVE_BTN)) { if (out.indexOf(id) === -1) { out.push(id); log('модификатор ' + id + ' добавлен в заказ (' + describeEl(row) + ')'); } continue; } if (row.querySelector(CONFIG.MOD_ADD_BTN)) { log('модификатор ' + id + ' доступен для добавления — в согласия не беру'); continue; } warn('строка модификатора ' + describeEl(row) + ': нет ни «убрать», ни «добавить» — считаю, что модификатор не добавлен'); } if (rows.length) log('модификаторы заказа: строк=' + rows.length + ', добавлено=' + out.length + ' [' + out.join(', ') + ']'); return out; } /* Есть ли ID модификатора в наборе (или null). */ function matchModifierSet(set, modifierIds) { var ids = offerIdList(set && set.id); for (var i = 0; i < ids.length; i++) { if (modifierIds.indexOf(ids[i]) !== -1) return ids[i]; } return null; } /* Название набора модификатора — для записи в ДП (на поведение не влияет). */ function modifierTitle(settings, modId) { var map = (settings && settings.modifiers) || {}; var keys = Object.keys(map); for (var i = 0; i < keys.length; i++) { if (matchModifierSet(map[keys[i]], [String(modId)])) { return String(map[keys[i]].name || keys[i]) + ' (модификатор ' + modId + ')'; } } return 'модификатор ' + modId + ' (набор не настроен)'; } /* Пункты наборов модификаторов, добавленных в заказ. ID пунктов получают префикс «mod_» — наборы школы переиспользуют одинаковые id (например cb_oferta), без префикса пункты модификатора и базового набора конфликтовали бы в DOM. */ function modifierItems(settings, modifierIds) { var wanted = (Array.isArray(modifierIds) ? modifierIds : offerIdList(modifierIds)).map(String); if (!wanted.length) return []; var map = settings.modifiers || {}; var keys = Object.keys(map); if (!keys.length) { warn('в заказ добавлены модификаторы [' + wanted.join(', ') + '], но в настройках нет ни одного набора ' + 'для модификаторов — дополнительную услугу покупатель не подтверждает. Настройте блок «Модификаторы» на ' + CONFIG.STORE_PATH + '.'); return []; } var out = []; wanted.forEach(function (modId) { var set = null; for (var i = 0; i < keys.length; i++) { if (matchModifierSet(map[keys[i]], [modId])) { set = map[keys[i]]; break; } } if (!set) { warn('модификатор ' + modId + ' добавлен в заказ, но набора для него в настройках нет — ' + 'пункт согласия не показываю (добавьте набор на ' + CONFIG.STORE_PATH + ')'); pay.unknownModifierIds.push(modId); return; } var visible = (set.items || []).filter(function (it) { return it.active !== false; }); if (!visible.length) { warn('у набора модификатора ' + modId + ' («' + (set.name || '') + '») нет видимых пунктов — показывать нечего'); return; } log('набор модификатора ' + modId + ' («' + (set.name || '') + '»): пунктов ' + visible.length); visible.forEach(function (it) { var copy = {}; Object.keys(it).forEach(function (k) { copy[k] = it[k]; }); copy.id = CONFIG.MODIFIER_ITEM_PREFIX + modId + '_' + it.id; copy.modifierId = modId; copy.setName = set.name || ''; out.push(copy); }); }); return out; } /* Пункты блока согласий: базовый набор (по предложению заказа либо по умолчанию) + пункты всех модификаторов, добавленных в заказ. Третий параметр необязательный — без него поведение прежнее (совместимо с вызовами из тестов и отладки). */ function pickItems(settings, offerIds, modifierIds) { 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(' | ') + ']'); var base = null; 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(', ') + ']' : '')); base = (set.items || []).filter(function (it) { return it.active !== false; }); break; } if (!base) { log('набор по умолчанию (совпадений с предложениями заказа нет)'); var def = settings.default && settings.default.items ? settings.default.items : []; base = def.filter(function (it) { return it.active !== false; }); } /* Модификаторы заказа — дополняют базовый набор, а не заменяют его. */ var extra = modifierItems(settings, modifierIds); if (extra.length) { log('pickItems: к базовому набору (' + base.length + ' пунктов) добавлено пунктов модификаторов: ' + extra.length + ' [' + extra.map(function (it) { return it.id; }).join(', ') + ']'); } return base.concat(extra); } 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) + ')'); if (pay.kind === 'overlay') { /* Оверлей: блок ставим сразу после блока заказа — выше способов оплаты (и основных, и альтернативных), которые виджет дорисовывает уже после заказа. Если заказ ещё не отрисован, блок уйдёт в конец виджета, и его перенесёт placeOverlayBlock. */ pay.overlayMoves = 0; var overlayAnchor = overlayBlockAnchor(pay.payRoot); var overlayHost = overlayAnchor === pay.payRoot ? pay.payRoot : overlayAnchor.parentNode; overlayHost.insertBefore(wrap, overlayAnchor === pay.payRoot ? null : overlayAnchor.nextElementSibling); log('блок согласий вставлен в виджет оплаты (после ' + describeEl(overlayAnchor) + ')'); } else { pay.payRoot.parentNode.insertBefore(wrap, pay.payRoot); log('блок согласий вставлен перед зоной оплаты'); } wrap.addEventListener('change', onPayChange); } 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.kind === 'overlay') { applyOverlayLock(locked); return; } 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 checkedIds() { return pay.items.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; }); } /* 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 || {}; var checked = checkedIds(); var need = requiredItems(); var payload = { dealId: String(pay.dealId || ''), userId: String(pay.userId || ''), offerIds: pay.offerIds.slice(), savedAt: new Date().toISOString(), settingsVersion: s.updatedAt || '', checkedIds: checked, items: [{ html: b64encode(payBlockHtml()) }] }; /* id покупателя заказа со страницы оплаты (params[CustomerKey]) — он нужен, чтобы позже до-записать согласия именно «своему» пользователю (см. verifyQueuedOwner). */ var buyer = collectBuyerInfo(); if (buyer.id) payload.buyerId = String(buyer.id); /* Модификаторы (апселлы) заказа: в записи видно, за какие дополнительные услуги покупатель давал согласия (сами формулировки уже внутри снапшота html). */ if (pay.modifierIds && pay.modifierIds.length) { payload.modifierIds = pay.modifierIds.slice(); payload.modifierNames = pay.modifierIds.map(function (id) { return modifierTitle(pay.settings, id); }); } /* Набор без обязательных пунктов: записываем факт показа согласий (и отметок, если были) с пометкой, что согласие не требовалось — так у школы есть запись даже без отметок, и видно, что скрипт сработал, а не «молчал». */ if (!need.length) { payload.optionalOnly = true; payload.note = checked.length ? 'Согласие не требовалось: в наборе нет обязательных пунктов. Отмечено: ' + checked.join(', ') : 'Согласие не требовалось: в наборе нет обязательных пунктов. Отметок нет.'; } /* Почта/телефон покупателя нужны только для канала SEND_IDENTITY: платформа определяет пользователя по этим полям, и запись проходит даже у гостя. В хранимую запись ДП они не попадают (страница подтверждений фильтрует поля). */ if (CONFIG.SEND_IDENTITY) { pay.identity = collectIdentity(); if (pay.identity.email || pay.identity.phone) payload.identity = pay.identity; log('payload: канал «почта/телефон» включён (email=' + (pay.identity.email ? 'есть' : 'нет') + ', phone=' + (pay.identity.phone ? 'есть' : 'нет') + ')'); } return payload; } var EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/; var PHONE_RE = /\+?\d[\d\s()-]{8,}\d/; function textOf(selector) { var el = document.querySelector(selector); if (!el) return ''; return String(el.textContent || '').replace(/\s+/g, ' ').trim(); } /* Последние 10 цифр телефона — для сравнения «тот ли это пользователь». */ function normalizePhone(value) { return String(value || '').replace(/\D/g, '').slice(-10); } /* Данные покупателя заказа со страницы оплаты. Работает и когда посетитель — гость: GetCourse кладёт id покупателя в params[CustomerKey] блока .getcourse-checkout-system (это id пользователя заказа, а не сессии — проверено 23.09.2026), а имя/почту/телефон показывает в .profileInfo / .xdget-currentUserInfo (заполняются в авторизованном виде и относятся тоже к покупателю заказа). Форма покупателя — последний источник. */ function collectBuyerInfo() { var out = { id: pay.buyerId, email: pay.buyerEmail, phone: pay.buyerPhone, source: pay.buyerSource }; if (!out.id) { var key = document.querySelector('input[name="params[CustomerKey]"]'); var id = key ? String(key.value || '').trim() : ''; if (/^\d{5,}$/.test(id)) { out.id = id; out.source = 'CustomerKey'; } } ['.profileInfo', '.xdget-currentUserInfo'].forEach(function (sel) { var text = textOf(sel); if (!text) return; /* Телефон может стоять вплотную к почте («+79166070251kirsanova967@ya.ru») — сначала находим телефон и ищем почту в тексте без него. */ var phoneMatch = PHONE_RE.exec(text); if (!out.phone && phoneMatch) out.phone = phoneMatch[0].trim(); var withoutPhone = phoneMatch ? text.split(phoneMatch[0]).join(' ') : text; if (!out.email) { var m = EMAIL_RE.exec(withoutPhone); if (m) out.email = m[0]; } }); if (!out.email) { var data = document.querySelector('input[name="params[DATA]"]'); var raw = data ? String(data.value || '') : ''; var em = /Email=([^;]+)/.exec(raw); if (em) out.email = em[1].trim(); } var pairs = [ ['input[type=email]', 'email'], ['input[name="email"]', 'email'], ['input[name="formParams[email]"]', 'email'], ['input[name="phone"]', 'phone'], ['input[name="formParams[phone]"]', 'phone'], ['input[type=tel]', 'phone'] ]; pairs.forEach(function (pair) { var el = document.querySelector(pair[0]); var value = el ? String(el.value || '').trim() : ''; if (!value) return; if (pair[1] === 'email' && !out.email && EMAIL_RE.test(value)) out.email = value; if (pair[1] === 'phone' && !out.phone && normalizePhone(value).length === 10) out.phone = value; }); if (out.id || out.email || out.phone) { var changed = out.id !== pay.buyerId || out.email !== pay.buyerEmail || out.phone !== pay.buyerPhone; pay.buyerId = out.id; pay.buyerEmail = out.email; pay.buyerPhone = out.phone; pay.buyerSource = out.source || 'разметка страницы'; if (changed) { log('покупатель заказа: id=' + (out.id || '—') + ', email=' + (out.email ? 'есть' : 'нет') + ', phone=' + (out.phone ? 'есть' : 'нет') + ' (источник: ' + pay.buyerSource + ')'); } } else if (!pay.buyerSource) { pay.buyerSource = 'нет данных'; } return { id: pay.buyerId, email: pay.buyerEmail, phone: pay.buyerPhone, source: pay.buyerSource }; } /* Почта/телефон покупателя — для канала SEND_IDENTITY и для очереди. */ function collectIdentity() { var info = collectBuyerInfo(); return { email: info.email, phone: info.phone }; } function onPayChange() { if (!pay.ready) { log('onPayChange: блок ещё не готов — пропускаю'); return; } log('onPayChange: ' + checkedState() + ' | не отмечено обязательных: [' + missingRequired().join(', ') + ']'); updateLock(); if (!requiredChecked()) { log('согласия сняты — оплата снова заблокирована'); return; } var checked = checkedIds().join(','); if (pay.saved) { /* Набор без обязательных пунктов: запись уже есть (записали при показе блока), но покупатель отметил пункты — обновляем её фактическими отметками. */ if (requiredItems().length === 0 && checked !== String(pay.lastSavedChecked || '')) { log('набор без обязательных пунктов, отметки изменились (' + (pay.lastSavedChecked || 'отметок не было') + ' → ' + (checked || 'отметок нет') + ') — обновляю запись'); attemptSave('изменились отметки', true); return; } log('согласия отмечены заново — повторная запись не нужна (saved=true)'); return; } attemptSave('изменение галочек'); } /* Одна попытка записи согласий. Вызывается из обработчика галочек, по таймеру повторов, при появлении пользователя в сессии и перед кликом по кнопке оплаты. Важное отличие от прежнего поведения: флаг «записано» (pay.saved) ставится ТОЛЬКО при успешной записи, иначе попытка повторяется, а данные кладутся в очередь для до-записи. */ function attemptSave(reason, force) { if (!pay.ready || pay.saving) return Promise.resolve(null); if (pay.saved && !force) return Promise.resolve(null); if (!requiredChecked()) return Promise.resolve(null); pay.saving = true; pay.attempts++; var payload = buildPayPayload(); var t0 = Date.now(); log('запись: попытка №' + pay.attempts + ' (' + (reason || 'изменение галочек') + '), заказ=' + pay.dealId + ', пользователь=' + safe(function () { return currentUserId(); }, 'гость')); return saveToConfirm(payload).then(function (result) { pay.saving = false; var ok = !!(result && result.ok); if (ok) { pay.saved = true; pay.lastError = null; /* Какие отметки ушли в запись (для наборов без обязательных пунктов, чтобы обновлять её). */ pay.lastSavedChecked = checkedIds().join(','); if (pay.retryTimer) { clearTimeout(pay.retryTimer); pay.retryTimer = null; } removePending(pay.dealId); setPayStatus(''); updateLock(); log('запись удалась с попытки №' + pay.attempts + ' за ' + (Date.now() - t0) + ' мс'); } else { pay.lastError = (result && result.reason) || 'платформа не подтвердила запись'; enqueuePending(payload); scheduleRetry(); setPayStatus('Не удалось сохранить подтверждение: ' + pay.lastError + '. Оплата доступна, повторю автоматически.', 'error'); warn('запись не удалась (попытка №' + pay.attempts + '): ' + pay.lastError); } return result; })['catch'](function (error) { pay.saving = false; pay.lastError = String((error && error.message) || error); enqueuePending(payload); scheduleRetry(); setPayStatus('Не удалось сохранить подтверждение: ' + pay.lastError + '. Оплата доступна, повторю автоматически.', 'error'); err('ошибка записи согласий (fail-open, попытка №' + pay.attempts + ') за ' + (Date.now() - t0) + ' мс:', error); return null; }); } /* --- Повторы, слежение за сессией и очередь (гарантированная запись) ---------------------- */ /* Планирует следующую попытку записи с нарастающей задержкой. */ function scheduleRetry() { if (pay.retryTimer || pay.saved) return; if (pay.attempts >= CONFIG.RETRY_MAX_ATTEMPTS) { warn('запись: попыток ' + pay.attempts + ' — останавливаюсь, согласия остаются в очереди ' + 'и будут до-записаны при следующем открытии заказа'); return; } var delay = CONFIG.RETRY_DELAYS[Math.min(pay.attempts - 1, CONFIG.RETRY_DELAYS.length - 1)]; log('запись: повтор через ' + Math.round(delay / 1000) + ' с (попыток было ' + pay.attempts + ')'); pay.retryTimer = setTimeout(function () { pay.retryTimer = null; attemptSave('таймер повтора'); }, delay); } /* Следим за появлением пользователя в сессии: гостю запись недоступна, поэтому как только GetCourse привязал сессию к покупателю (accountUserId), пробуем записать ещё раз. */ function watchAuthorization() { if (pay.authTimer) return; if (typeof setInterval !== 'function') return; var before = currentUserId(); pay.authTicks = 0; pay.authTimer = setInterval(function () { pay.authTicks++; var now = currentUserId(); if (now && now !== before) { log('сессия стала пользовательской (' + (before || 'гость') + ' → ' + now + ') — пробую записать согласия'); before = now; attemptSave('появился пользователь в сессии'); } if (pay.saved || pay.authTicks > CONFIG.AUTH_POLL_LIMIT) { if (typeof clearInterval === 'function') clearInterval(pay.authTimer); pay.authTimer = null; } }, CONFIG.AUTH_POLL_MS); } /* Перед кликом по кнопке оплаты делаем последнюю попытку записи: сессия могла стать пользовательской уже после того, как покупатель отметил галочки. */ function hookPayButtons() { if (pay.payRoot && pay.payRoot.getAttribute('data-gsoc-click-hooked')) return; if (!document.addEventListener) return; if (pay.payRoot) pay.payRoot.setAttribute('data-gsoc-click-hooked', '1'); ['click', 'mousedown'].forEach(function (type) { document.addEventListener(type, function (event) { var t = event.target; if (!t || !t.closest) return; /* Кнопки обычной страницы оплаты и кнопки способов оплаты внутри виджета оверлея. */ if (!t.closest(CONFIG.PAY_BUTTONS) && !t.closest(CONFIG.WIDGET_BUTTONS)) return; if (!pay.ready || pay.saved || !requiredChecked()) return; log('нажатие кнопки оплаты (' + type + ') — последняя попытка записи согласий'); attemptSave('перед оплатой'); }, true); }); } /* --- Очередь отложенных согласий (localStorage) ------------------------------------------ */ function queueRead() { try { var parsed = JSON.parse(localStorage.getItem(CONFIG.KEY_QUEUE) || '{}') || {}; var out = {}; Object.keys(parsed).forEach(function (k) { var item = parsed[k]; if (item && item.payload && item.at && (Date.now() - item.at) < CONFIG.QUEUE_TTL) out[k] = item; }); return out; } catch (e) { return {}; } } function queueWrite(map) { try { localStorage.setItem(CONFIG.KEY_QUEUE, JSON.stringify(map)); } catch (e) {} } function enqueuePending(payload) { if (!payload || !payload.dealId) return; var map = queueRead(); var previous = map[String(payload.dealId)]; map[String(payload.dealId)] = { payload: payload, at: Date.now(), attempts: pay.attempts, /* Кому до-записывать: id покупателя заказа (если страница его отдала) и почта/телефон. */ buyerId: payload.buyerId || (previous && previous.buyerId) || null, identity: collectIdentity() }; var keys = Object.keys(map); if (keys.length > CONFIG.QUEUE_LIMIT) { keys.sort(function (a, b) { return (map[a].at || 0) - (map[b].at || 0); }); keys.slice(0, keys.length - CONFIG.QUEUE_LIMIT).forEach(function (k) { delete map[k]; }); } queueWrite(map); log('очередь: согласия по заказу ' + payload.dealId + ' отложены' + (map[String(payload.dealId)].buyerId ? ' (покупатель ' + map[String(payload.dealId)].buyerId + ')' : '') + '; в очереди ' + Object.keys(map).length); } function removePending(dealId) { var map = queueRead(); if (!map[String(dealId)]) return; delete map[String(dealId)]; queueWrite(map); log('очередь: заказ ' + dealId + ' убран из очереди'); } function pendingList() { var map = queueRead(); return Object.keys(map).map(function (k) { return { dealId: k, at: map[k].at, attempts: map[k].attempts, buyerId: map[k].buyerId || null }; }); } /* Полные записи очереди (с payload) — для до-записи. */ function pendingItems() { var map = queueRead(); return Object.keys(map).map(function (k) { var item = map[k]; return { dealId: k, at: item.at, attempts: item.attempts, payload: item.payload, buyerId: item.buyerId || null, identity: item.identity || null }; }); } /* --- Кому до-записывать: проверка «того ли пользователя» --------------------------------- */ function cachedContacts(userId) { try { var raw = JSON.parse(localStorage.getItem(CONFIG.KEY_MY_CONTACTS) || 'null'); if (raw && String(raw.userId) === String(userId) && (Date.now() - raw.at) < CONFIG.CONTACTS_TTL) return raw.value; } catch (e) {} return null; } function storeContacts(userId, value) { try { localStorage.setItem(CONFIG.KEY_MY_CONTACTS, JSON.stringify({ userId: String(userId), at: Date.now(), value: value })); } catch (e) {} } /* Почта текущего пользователя. Нужна только когда в записи очереди нет id покупателя. Источники: переменные страницы → страница /user/my/profile (разбор блока «E-mail»). */ function currentUserContacts() { var userId = currentUserId(); if (!userId) return Promise.resolve({ email: null, phone: null, source: 'гость' }); var cached = cachedContacts(userId); if (cached) return Promise.resolve(cached); var ui = window.userInfo || {}; var gu = window.gcUser || {}; var fromVars = { email: ui.email || gu.email || null, phone: ui.phone || gu.phone || null, source: 'переменные страницы' }; if (fromVars.email || fromVars.phone) { storeContacts(userId, fromVars); return Promise.resolve(fromVars); } return fetch(CONFIG.MY_PROFILE_PATH, { credentials: 'include' }).then(function (r) { return r.text(); }).then(function (html) { var raw = String(html || ''); var flat = raw.replace(/\s+/g, ' '); var emailM = /\s*E-?mail\s*<\/strong>\s*:?\s*([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/i.exec(flat); var out = { email: emailM ? emailM[1] : null, phone: (PHONE_RE.exec(/\s*Телефон\s*<\/strong>\s*:?\s*([+\d][\d\s()-]{9,})/i.exec(flat) ? flat : '') || [])[0] || null, source: 'страница профиля' }; if (!out.email) { /* Разметка изменилась: берём единственный email на странице профиля. */ var all = []; raw.replace(EMAIL_RE, function (m) { if (all.indexOf(m) === -1) all.push(m); return m; }); if (all.length === 1) out.email = all[0]; } if (out.email || out.phone) storeContacts(userId, out); log('контакты текущего пользователя: email=' + (out.email ? 'есть' : 'нет') + ', phone=' + (out.phone ? 'есть' : 'нет') + ' (' + out.source + ')'); return out; })['catch'](function (e) { warn('не удалось получить контакты текущего пользователя: ' + ((e && e.message) || e)); return { email: null, phone: null, source: 'ошибка' }; }); } /* Свой ли это пользователь? own — можно писать, foreign — нельзя, unknown — пишем только на страницах своего заказа. */ function verifyQueuedOwner(item) { var me = currentUserId(); if (!me) return Promise.resolve('foreign'); if (item.buyerId) return Promise.resolve(String(item.buyerId) === String(me) ? 'own' : 'foreign'); var identity = item.identity || {}; if (!identity.email && !identity.phone) return Promise.resolve('unknown'); return currentUserContacts().then(function (contacts) { if (identity.email && contacts.email && String(identity.email).toLowerCase() === String(contacts.email).toLowerCase()) return 'own'; if (identity.phone && contacts.phone && normalizePhone(identity.phone) && normalizePhone(identity.phone) === normalizePhone(contacts.phone)) return 'own'; if (contacts.email || contacts.phone) return 'foreign'; return 'unknown'; }); } function queueThrottled() { try { var at = parseInt(localStorage.getItem(CONFIG.KEY_QUEUE_THROTTLE) || '0', 10) || 0; return (Date.now() - at) < CONFIG.QUEUE_THROTTLE_MS; } catch (e) { return false; } } function markQueueRun() { try { localStorage.setItem(CONFIG.KEY_QUEUE_THROTTLE, String(Date.now())); } catch (e) {} } /* До-запись отложенных согласий: выполняется при загрузке любой страницы школы, а также на странице оплаты и в карточке своего заказа. Порядок для каждой записи: 1) читаем текущее ДП — если запись по этому заказу уже есть, просто чистим очередь; 2) проверяем, что заказ принадлежит текущему пользователю (id покупателя / почта-телефон); 3) если владелец подтверждён (или это «своя» страница заказа) — записываем. */ function deliverQueue(reason, options) { options = options || {}; if (isAdminViewer()) { if (pendingList().length) log('очередь: админский вид — отложенные согласия не до-записываю'); return Promise.resolve(0); } var items = pendingItems(); var all = pendingList(); if (!items.length) return Promise.resolve(0); if (!currentUserId()) { log('очередь: пользователь не авторизован — до-запись отложена (' + all.length + ' записей)'); return Promise.resolve(0); } if (queueThrottled()) { log('очередь: до-запись уже запускалась меньше ' + Math.round(CONFIG.QUEUE_THROTTLE_MS / 60000) + ' мин назад — пропускаю'); return Promise.resolve(0); } markQueueRun(); items = items.slice(0, CONFIG.QUEUE_DELIVER_LIMIT); var ownDealId = options.dealId ? String(options.dealId) : null; log('очередь: проверяю ' + items.length + ' записей из ' + all.length + ' (' + reason + ')'); var written = 0; return items.reduce(function (chain, item) { return chain.then(function () { return deliverOne(item, ownDealId).then(function (done) { if (done) written++; }); }); }, Promise.resolve()).then(function () { pay.queueDelivery = { reason: reason, at: Date.now(), checked: items.length, written: written, pending: pendingList().length }; log('очередь: итог до-записи — записано ' + written + ', в очереди осталось ' + pendingList().length); return written; }); } /* Одна запись из очереди. */ function deliverOne(item, ownDealId) { var payload = item.payload; if (!payload || !item.dealId) { removePending(item.dealId); return Promise.resolve(false); } return readFromConfirm().then(function (current) { var records = (current && current.orders) || {}; if (records[String(item.dealId)]) { removePending(item.dealId); log('очередь: по заказу ' + item.dealId + ' запись уже есть — убираю из очереди'); return false; } return verifyQueuedOwner(item).then(function (verdict) { var ownPage = !!ownDealId && String(item.dealId) === ownDealId; if (verdict === 'foreign') { warn('очередь: заказ ' + item.dealId + ' принадлежит другому пользователю — до-запись отменена'); return false; } if (verdict === 'unknown' && !ownPage) { log('очередь: заказ ' + item.dealId + ' — владельца подтвердить не удалось, ' + 'до-запись только на его странице оплаты'); return false; } log('очередь: до-записываю заказ ' + item.dealId + ' (владелец: ' + verdict + ', отложено ' + Math.round((Date.now() - item.at) / 1000) + ' с назад)'); return saveToConfirm(payload).then(function (result) { if (result && result.ok) { removePending(item.dealId); if (ownPage && String(pay.dealId || '') === String(item.dealId)) { pay.saved = true; setPayStatus(''); updateLock(); } log('очередь: заказ ' + item.dealId + ' до-записан'); return true; } warn('очередь: заказ ' + item.dealId + ' до-записать не удалось: ' + ((result && result.reason) || '—')); return false; }); }); })['catch'](function (e) { warn('очередь: ошибка до-записи по заказу ' + item.dealId + ': ' + ((e && e.message) || e)); return false; }); } /* После того как блок согласий готов: следим за сессией, страхуем клик по оплате и до-записываем отложенные согласия по этому заказу (если покупатель вернулся). */ /* --- Оплата в оверлее: виджет оплаты GetCourse (.gcpay-widget) --------------------- «Оплата в оверлее» — виджет оплаты справа на странице школы (Vue-приложение в #app). Заказ и предложения берём из его сторов Pinia: detail.dealId и deal.deal.positions[].offerId. Согласия вставляются в виджет перед способами оплаты, а сами способы оплаты закрыты, пока не отмечены обязательные пункты. */ function payWidget() { return document.querySelector(CONFIG.PAY_WIDGET); } /* Данные заказа из сторот виджета. null — если стор недоступен (другая сборка виджета). */ function widgetContext() { var out = null; try { var host = document.querySelector('#app') || payWidget(); var vueApp = host && host.__vue_app__; var props = vueApp && vueApp.config ? vueApp.config.globalProperties : null; var stores = props && props.$pinia && props.$pinia._s && props.$pinia._s.get ? props.$pinia._s : null; var detail = stores ? stores.get('detail') : null; var dealStore = stores ? stores.get('deal') : null; var deal = dealStore && dealStore.$state ? dealStore.$state.deal : null; var dealId = (detail && detail.$state && detail.$state.dealId) || (deal && deal.id) || null; if (!dealId) return null; var offers = []; if (deal && deal.positions) { deal.positions.forEach(function (p) { var id = p && p.offerId != null ? String(p.offerId) : ''; if (id && offers.indexOf(id) === -1) offers.push(id); }); } out = { dealId: String(dealId), offerIds: offers, email: (deal && deal.email) || '', number: (deal && deal.number) || '', sum: (deal && deal.sum) || 0 }; } catch (e) { warn('оплата в оверлее: не удалось прочитать состояние виджета (' + ((e && e.message) || e) + ')'); return null; } log('оплата в оверлее: заказ=' + out.dealId + (out.number ? ' (номер ' + out.number + ')' : '') + ', сумма=' + out.sum + ', предложения=[' + (out.offerIds.join(', ') || '—') + ']' + ', почта=' + (out.email ? 'есть' : 'нет')); return out; } function afterPayReady() { pay.ready = true; collectBuyerInfo(); watchPay(); hookPayButtons(); watchAuthorization(); deliverQueue('страница оплаты', { dealId: pay.dealId }); /* Набор без обязательных пунктов: оплату ничто не блокирует, поэтому записываем согласия сразу при показе блока — иначе покупатель мог уйти со страницы без отметок и без записи. */ if (!requiredItems().length) { log('в наборе нет обязательных пунктов — записываю согласия сразу при показе блока'); attemptSave('набор без обязательных пунктов'); } } /* Куда ставить блок в виджете: сразу после блока заказа (.gcpay-widget-order) — выше и основных, и альтернативных способов оплаты. Если заказ ещё не отрисован, берём контейнер виджета. */ function overlayBlockAnchor(widget) { return widget.querySelector(CONFIG.WIDGET_ORDER) || widget.querySelector(CONFIG.WIDGET_METHODS) || widget.querySelector('.gcpay-widget-container') || widget; } /* Виджет рисует части по очереди: сначала пустой корень, потом блок заказа и способы оплаты. Если на момент вставки блока заказа ещё не было, согласия нужно один раз перенести в контейнер заказа. Переносим только при явном несовпадении места (иначе перестановки на каждую перерисовку Vue зацикливаются) и не больше CONFIG.WIDGET_PLACE_LIMIT раз. */ function placeOverlayBlock() { var widget = payWidget(); if (!widget || !pay.blockEl || !pay.blockEl.parentNode) return false; if (pay.overlayMoves >= CONFIG.WIDGET_PLACE_LIMIT) return false; var order = widget.querySelector(CONFIG.WIDGET_ORDER); if (!order || !order.parentNode) return false; if (pay.blockEl.parentNode === order.parentNode) return false; order.parentNode.insertBefore(pay.blockEl, order.nextElementSibling); pay.overlayMoves++; log('блок согласий перенесён в контейнер заказа (сразу после ' + describeEl(order) + '), переносов: ' + pay.overlayMoves); return true; } /* Закрываем способы оплаты внутри виджета: класс на виджете + disabled у кнопок. */ function applyOverlayLock(locked) { var wrap = payWidget(); if (!wrap) { warn('оплата в оверлее: виджет пропал со страницы — блокировку не меняю'); return; } wrap.classList[locked ? 'add' : 'remove'](CONFIG.WIDGET_LOCK_CLASS); var methods = wrap.querySelector(CONFIG.WIDGET_METHODS); var buttons = methods ? [].slice.call(methods.querySelectorAll('button')) : []; buttons.forEach(function (b) { if (locked) { if (!b.getAttribute('data-gsoc-disabled')) b.setAttribute('data-gsoc-disabled', b.disabled ? '1' : '0'); b.setAttribute('disabled', 'disabled'); } else { if (b.getAttribute('data-gsoc-disabled') === '0') b.removeAttribute('disabled'); b.removeAttribute('data-gsoc-disabled'); } }); pay.locked = !!locked; log('оплата в оверлее ' + (locked ? 'закрыта' : 'открыта') + ': ' + describeEl(wrap) + ' (кнопок ' + buttons.length + ')'); } /* Виджет создаётся после отправки формы — ждём его появления на странице. */ function watchPayWidget() { if (watchPayWidget._started || !window.MutationObserver || !document.body) return; watchPayWidget._started = true; log('оплата в оверлее: жду появления виджета ' + CONFIG.PAY_WIDGET); new MutationObserver(function () { if (!payWidget()) return; if (pay.kind === 'overlay') { placeOverlayBlock(); checkOverlayChanged(); return; } log('оплата в оверлее: виджет появился — включаю согласия'); startOverlayPayMode(); }).observe(document.body, { childList: true, subtree: true }); } 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; /* Модификаторы (апселлы), добавленные покупателем: их пункты дополнят набор согласий. */ pay.modifierIds = collectModifierIds(); pay.modifierSignature = pay.modifierIds.join(','); log('страница оплаты: заказ=' + pay.dealId + ', предложения=' + (pay.offerIds.join(',') || '—') + ', пользователь=' + pay.userId); log('страница оплаты: модификаторы заказа=[' + (pay.modifierIds.join(', ') || '—') + ']' + (pay.modifierIds.length ? ' — их пункты добавятся к набору согласий' : '')); 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) { var decision = modeAllowsWork(settings); pay.mode = decision.mode; log('режим работы: ' + decision.mode + ' — ' + MODES[decision.mode]); if (!decision.work) { warn(decision.reason); return; } log(decision.reason); pay.items = pickItems(settings, pay.offerIds, pay.modifierIds); pay.setName = pickedSetName(settings, pay.offerIds); if (!pay.items.length) { warn('нет ни одного видимого пункта: ни в наборе согласий [' + pay.setName + '], ни у модификаторов заказа [' + (pay.modifierIds.join(', ') || '—') + '] — оплату не блокирую'); 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(); afterPayReady(); log('готово: пунктов ' + pay.items.length + ', обязательных 0 (оплата не блокируется), за ' + (Date.now() - t0) + ' мс; ' + describeSettings(settings)); return; } renderPayBlock(); applyLock(true); afterPayReady(); 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)); }); } /* Заказ можно изменить, не перезагружая страницу (некоторые шаблоны добавляют модификатор ajax-ом). Если состав ДОБАВЛЕННЫХ модификаторов изменился — пересобираю блок согласий: пункты обновляются, отметки сбрасываются, оплата снова блокируется. */ function checkModifiersChanged() { if (!pay.ready || !pay.settings) return; var now = collectModifierIds(); var signature = now.join(','); if (signature === pay.modifierSignature) return; warn('состав модификаторов заказа изменился («' + (pay.modifierSignature || '—') + '» → «' + (signature || '—') + '») — пересобираю блок согласий'); pay.modifierIds = now; pay.modifierSignature = signature; pay.unknownModifierIds = []; pay.items = pickItems(pay.settings, pay.offerIds, pay.modifierIds); pay.saved = false; pay.lastSavedChecked = null; if (pay.blockEl && pay.blockEl.parentNode) pay.blockEl.parentNode.removeChild(pay.blockEl); pay.blockEl = null; if (!pay.items.length) { warn('после изменения состава модификаторов видимых пунктов не осталось — блок согласий не рисую'); if (pay.locked) applyLock(false); return; } renderPayBlock(); if (requiredItems().length) applyLock(true); else if (pay.locked) applyLock(false); } /* Данные заказа в виджете приходят частями: сначала заказ без предложений, потом позиции. Если изменился заказ ИЛИ состав предложений — пересобираем блок согласий (иначе набор мог остаться «по умолчанию»). */ function checkOverlayChanged() { var ctx = widgetContext(); if (!ctx || !ctx.dealId) return; var sameDeal = String(ctx.dealId) === String(pay.dealId); var sameOffers = ctx.offerIds.join(',') === (pay.offerIds || []).join(','); if (sameDeal && sameOffers) return; if (pay.overlayRebuilds >= CONFIG.WIDGET_PLACE_LIMIT) { warn('оплата в оверлее: данные заказа меняются подозрительно часто — больше не пересобираю блок (лимит ' + CONFIG.WIDGET_PLACE_LIMIT + ')'); return; } pay.overlayRebuilds++; warn('оплата в оверлее: данные заказа изменились (заказ ' + (pay.dealId || '—') + ' → ' + ctx.dealId + ', предложения [' + ((pay.offerIds || []).join(', ') || '—') + '] → [' + (ctx.offerIds.join(', ') || '—') + ']) — пересобираю блок согласий'); if (pay.blockEl && pay.blockEl.parentNode) pay.blockEl.parentNode.removeChild(pay.blockEl); pay.blockEl = null; pay.saved = false; pay.lastSavedChecked = null; pay.lastError = null; pay.attempts = 0; startOverlayPayMode(); } /* Режим «оплата в оверлее»: та же логика согласий, что и на странице оплаты. */ function startOverlayPayMode() { pay.kind = 'overlay'; pay.payRoot = payWidget(); if (!pay.payRoot) { warn('оплата в оверлее: виджета нет — выхожу'); return; } var ctx = widgetContext(); if (!ctx || !ctx.dealId) { warn('оплата в оверлее: номер заказа определить не удалось (нет данных виджета) — согласия не показываю (fail-open)'); return; } pay.dealId = ctx.dealId; pay.offerIds = ctx.offerIds.slice(); pay.userId = currentUserId(); pay.modifierIds = collectModifierIds(); pay.modifierSignature = pay.modifierIds.join(','); if (ctx.email && !pay.buyerEmail) { pay.buyerEmail = ctx.email; pay.buyerSource = 'виджет оплаты'; } var t0 = Date.now(); loadSettings().then(function (settings) { var decision = modeAllowsWork(settings); pay.mode = decision.mode; log('режим работы: ' + decision.mode + ' — ' + MODES[decision.mode]); if (!decision.work) { warn(decision.reason); return; } log(decision.reason); if (!payWidget()) { warn('оплата в оверлее: виджет исчез — выхожу'); return; } pay.items = pickItems(settings, pay.offerIds, pay.modifierIds); pay.setName = pickedSetName(settings, pay.offerIds); if (!pay.items.length) { warn('оплата в оверлее: нет ни одного видимого пункта (набор [' + pay.setName + '], модификаторы [' + (pay.modifierIds.join(', ') || '—') + ']) — оплату не блокирую'); return; } renderPayBlock(); if (requiredItems().length) applyLock(true); afterPayReady(); log('оплата в оверлее: готово — пунктов ' + pay.items.length + ', обязательных ' + requiredItems().length + ', за ' + (Date.now() - t0) + ' мс; ' + describeSettings(settings)); })['catch'](function (error) { warn('оплата в оверлее: fail-open — ' + ((error && error.message) || error)); }); } function watchPay() { if (!window.MutationObserver || watchPay._started) return; watchPay._started = true; new MutationObserver(function () { /* В оверлее виджет может быть перерисован — перепривязываемся к актуальному. */ if (pay.kind === 'overlay') { var freshWidget = payWidget(); if (freshWidget) pay.payRoot = freshWidget; } 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(); } checkModifiersChanged(); if (pay.kind === 'overlay') { placeOverlayBlock(); checkOverlayChanged(); } }).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(''); var parts = []; if (html) { var tmp = document.createElement('div'); tmp.innerHTML = html; [].slice.call(tmp.querySelectorAll('[style]')).forEach(function (el) { el.removeAttribute('style'); }); parts.push('
' + tmp.innerHTML + '
'); } /* Пометка и список отметок: нужны, когда согласие не требовалось (в наборе нет обязательных пунктов) — иначе в карточке не было бы видно, что скрипт сработал. */ if (record && record.note) parts.push('
' + esc(record.note) + '
'); if (record && record.checkedIds && record.checkedIds.length && !record.note) { parts.push('
Отмечено: ' + esc(record.checkedIds.join(', ')) + '
'); } if (record && record.buyerId && record.userId && String(record.buyerId) !== String(record.userId)) { parts.push('
Покупатель заказа: ' + esc(record.buyerId) + '
'); } if (!parts.length) return ''; parts.push('
Подтверждено: ' + formatDate(record.savedAt) + '
'); return parts.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('') : ''; if (!body) { body = '
Нет информации о подтверждённых согласиях по этому заказу.
'; } 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(); /* Если по этому заказу остались неотправленные согласия (покупатель оплачивал гостем) — до-записываем их сейчас: в своём заказе сессия пользовательская, запись пройдёт. */ if (order.isSelf) { deliverQueue('карточка заказа', { dealId: order.dealIds[0] || null }).then(function (done) { if (done) { loader().then(function (fresh) { order.data = fresh; renderOrderRow(); })['catch'](function () {}); } }); } })['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; } /* «Оплата в оверлее»: виджет оплаты на странице школы. Он появляется после отправки формы, поэтому либо включаемся сразу, либо ждём его появления. */ if (payWidget()) { log('режим: оплата в оверлее (виджет ' + CONFIG.PAY_WIDGET + ' уже на странице)'); startOverlayPayMode(); return; } watchPayWidget(); log('подходящий режим не найден — ничего не делаю (не страница оплаты и не карточка заказа)'); /* Но если в этом браузере остались неотправленные согласия (покупатель оплачивал гостем), пробуем до-записать их сейчас: на любой странице школы, если сессия пользовательская. В режиме «Выключено» не пишем ничего, поэтому сначала читаем настройки. */ if (pendingList().length) { loadSettings().then(function (settings) { if (settingsMode(settings) === 'off') { log('очередь: режим настроек «Выключено» — отложенные согласия не до-записываю'); return; } deliverQueue('загрузка страницы'); })['catch'](function (error) { warn('очередь: настройки недоступны, до-запись отложена (' + ((error && error.message) || error) + ')'); }); } } window.GSOC = { version: VERSION, logLevel: function () { return LOG_LEVEL; }, setLog: setLogLevel, pay: pay, order: order, config: CONFIG, offerIdList: offerIdList, pickItems: pickItems, /* Модификаторы (апселлы) заказа — проверка в консоли: GSOC.modifierIds(). */ modifierIds: collectModifierIds, modifierTitle: modifierTitle, 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' : (payWidget() ? 'overlay' : 'other')), payKind: pay.kind, signals: pageSignals(), settings: describeSettings(pay.settings), pay: { dealId: pay.dealId, offerIds: pay.offerIds, mode: pay.mode, 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(), /* Запись согласий: сколько попыток было, чем закончилась последняя и что ждёт отправки. */ attempts: pay.attempts, lastError: pay.lastError, retryScheduled: !!pay.retryTimer, sessionUser: safe(function () { return currentUserId(); }, 'гость'), /* Покупатель заказа со страницы оплаты и отметки (для записей без обязательных пунктов). */ buyerId: pay.buyerId, buyerSource: pay.buyerSource, buyerEmail: pay.buyerEmail ? 'есть' : null, checkedIds: checkedIds(), optionalOnly: !requiredItems().length, /* Модификаторы (апселлы) заказа: что найдено на странице и что настроено. */ modifierIds: pay.modifierIds, modifierItems: pay.items.filter(function (it) { return !!it.modifierId; }).length, modifierSets: Object.keys((pay.settings && pay.settings.modifiers) || {}).length, unknownModifiers: pay.unknownModifierIds, queueDelivery: pay.queueDelivery, myContacts: cachedContacts(currentUserId()), pending: pendingList() }, 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(); } })();