/* =====================================================================================
КЛИЕНТСКАЯ СБОРКА ШКОЛЫ anarbaeva.pro (отдельная копия, оригинал — src/gs-ofert.js).
-------------------------------------------------------------------------------------
Отличие от оригинала (версия 11 → 12 → 13 → 15 → 16): поддержка модификаторов (апселлов) GetCourse
и «оплаты в оверлее» — виджета оплаты GetCourse на странице школы (.gcpay-widget). В оверлее
блок согласий ставится сразу после блока заказа, а пока он не подтверждён, закрываются и
основные, и альтернативные способы оплаты.
Версия 16: на странице оплаты блок согласий встаёт ПЕРЕД всей группой «выбор способа оплаты»
(.payHeader / .payMethods / .payForms), и эта группа скрыта, пока не отмечены обязательные
пункты. Зона оплаты выбирается по видимым элементам: спрятанный вёрсткой школы .xdget-payform
(внутри .originalPay) больше не считается зоной оплаты.
Если покупатель добавил к заказу модификатор, к набору согласий добавляются пункты
этого модификатора (набор «модификатор → пункты» настраивается в клиентской админке
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 = 16;
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]; }
/* --- Вывод логов -------------------------------------------------------------
В консоль браузера НЕ пишем: на боевых страницах логи засоряли консоль.
Вернуть вывод — раскомментировать строку console[...] в emit() ниже
(и при необходимости включить уровень: GSOC.setLog('debug')).
Буфер для отладки и тестов: window.GSOC_LOG_BUFFER = { log: [], warn: [], error: [] } —
в него попадают те же строки, что раньше уходили в консоль. */
function emit(method, args) {
var parts = [PREFIX].concat([].slice.call(args));
// try { console[method].apply(console, parts); } catch (e) {}
try {
var buf = window.GSOC_LOG_BUFFER;
if (buf && buf[method]) buf[method].push(parts.map(function (a) { return String(a); }).join(' '));
} catch (e) {}
}
function log() { if (!allowLog('debug')) return; emit('log', arguments); }
function warn() { if (!allowLog('warn')) return; emit('warn', arguments); }
function err() { if (!allowLog('error')) return; emit('error', arguments); }
/* Включить/выключить логи на живой странице: 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')) {
emit('log', ['уровень логов: ' + level + ' (запомнил в localStorage.gsoc_log; вернуть полностью — ' +
LOG_BACK_ON + ' или ?gsoc=debug в адресе)']);
}
return LOG_LEVEL;
}
/* Первая строка — версия сборки: печатается, пока уровень не ниже warn. */
if (allowLog('warn')) emit('log', ['itteration: ' + VERSION]);
/* --- вспомогательные функции для логов (не должны ломать работу скрипта) --- */
/* Безопасный вызов: нет 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'],
/* Блоки «выбор способа оплаты» конструктора страниц GetCourse. Блок согласий встаёт
перед первым из них, а сами блоки скрываются, пока обязательные согласия не отмечены.
Если на странице таких блоков нет — работаем с зоной оплаты (PAY_ROOTS). */
PAY_UI_BLOCKS: '.payHeader, .payMethods, .payForms',
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,
/* Блоки, которые скрыты ради согласий (обычно вся группа «выбор оплаты»). */
lockTargets: [],
/* Диагностика и повторы записи согласий (см. 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;
}
/* Виден ли элемент на странице: у скрытого (display:none у него самого или у
родителя) список прямоугольников пустой. */
function isVisible(el) {
if (!el) return false;
try {
if (typeof el.getClientRects === 'function') return el.getClientRects().length > 0;
} catch (e) {}
return !!(el.offsetWidth || el.offsetHeight);
}
/* Видимый блок с кнопками оплаты. Нужен, когда «стандартная» зона оплаты спрятана
вёрсткой школы (например .xdget-area:not(.editable) .originalPay{display:none}),
а кнопки нарисованы в собственном блоке страницы. Берём самый глубокий блок,
внутри которого лежат ВСЕ видимые кнопки оплаты. */
function visiblePayButtonsRoot() {
var all = document.querySelectorAll(CONFIG.PAY_BUTTONS);
var seen = [];
for (var i = 0; i < all.length; i++) {
if (isVisible(all[i])) seen.push(all[i]);
}
if (!seen.length) return null;
var node = seen[0];
while (node && node.parentNode && node !== document.body) {
var holdsAll = true;
for (var j = 0; j < seen.length; j++) {
if (!node.contains || !node.contains(seen[j])) { holdsAll = false; break; }
}
if (holdsAll) return node;
node = node.parentNode;
}
return seen[0].parentNode || null;
}
/* Зона оплаты — первый ВИДИМЫЙ кандидат из CONFIG.PAY_ROOTS. Раньше брался первый
найденный: если он скрыт, блок согласий вставлялся в невидимый контейнер
(покупатель его не видел), а блокировка ложилась на скрытые кнопки. */
function findPayRoot() {
var hidden = null;
for (var i = 0; i < CONFIG.PAY_ROOTS.length; i++) {
var el = document.querySelector(CONFIG.PAY_ROOTS[i]);
if (!el) continue;
if (isVisible(el)) {
log('зона оплаты найдена по селектору "' + CONFIG.PAY_ROOTS[i] + '" → ' + describeEl(el));
return el;
}
if (!hidden) hidden = { el: el, selector: CONFIG.PAY_ROOTS[i] };
warn('кандидат "' + CONFIG.PAY_ROOTS[i] + '" есть в разметке, но скрыт (display:none) → ' + describeEl(el));
}
var visibleRoot = visiblePayButtonsRoot();
if (visibleRoot) {
warn('зона оплаты: все кандидаты скрыты, беру видимый блок кнопок оплаты → ' + describeEl(visibleRoot));
return visibleRoot;
}
if (hidden) {
log('зона оплаты найдена по селектору "' + hidden.selector + '" → ' + describeEl(hidden.el));
warn('селектор "' + hidden.selector + '" скрыт (display:none) и видимых кнопок оплаты нет — блок согласий может остаться невидимым');
return hidden.el;
}
warn('зона оплаты не найдена. Проверенные селекторы: ' + CONFIG.PAY_ROOTS.join(' | '));
return null;
}
/* Видимые блоки «выбор способа оплаты» (заголовок, переключатели, формы). */
function payUiBlocks() {
var nodes = document.querySelectorAll(CONFIG.PAY_UI_BLOCKS);
var out = [];
for (var i = 0; i < nodes.length; i++) {
if (isVisible(nodes[i])) out.push(nodes[i]);
}
return out;
}
/* Что скрываем, пока согласия не отмечены: вся группа выбора оплаты, а если её
на странице нет — сама зона оплаты. */
function payUiTargets() {
var blocks = payUiBlocks();
if (blocks.length) return blocks;
return pay.payRoot ? [pay.payRoot] : [];
}
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 = [
'
Чтобы перейти к оплате, отметьте все обязательные согласия.
'
].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 {
/* Страница оплаты: блок согласий — перед всей группой «выбор способа оплаты»
(заголовок, переключатели, методы): покупатель сначала отмечает согласия,
а способы оплаты до этого скрыты (см. applyLock / CONFIG.PAY_UI_BLOCKS). */
var targets = payUiTargets();
pay.lockTargets = targets;
var anchor = targets.length ? targets[0] : pay.payRoot;
anchor.parentNode.insertBefore(wrap, anchor);
log('блок согласий вставлен перед блоком выбора оплаты (' + describeEl(anchor) +
', скрываем блоков: ' + targets.length + ')');
}
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;
/* Скрываем не только зону оплаты, но и заголовок/переключатели способов оплаты:
пока обязательные согласия не отмечены, покупатель не должен переключать
«Оплата картой / Рассрочка / из-за рубежа» и жать кнопки методов. */
var targets = locked ? payUiTargets() : ((pay.lockTargets && pay.lockTargets.length) ? pay.lockTargets : payUiTargets());
if (locked) pay.lockTargets = targets;
if (!targets.length) targets = [pay.payRoot];
if (locked) {
targets.forEach(function (el) {
el.classList.add(CONFIG.LOCK_CLASS);
el.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 {
targets.forEach(function (el) {
el.classList.remove(CONFIG.LOCK_CLASS);
el.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.lockTargets = [];
}
pay.locked = !!locked;
log('оплата ' + (locked ? 'заблокирована' : 'разблокирована') + ' (кнопок ' + payButtons().length +
', скрытых блоков ' + targets.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) +
', блоки: ' + targets.map(describeEl).join(' | ') +
', кнопок с 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) {
var hidden = true;
var targets = (pay.lockTargets && pay.lockTargets.length) ? pay.lockTargets : [pay.payRoot];
for (var i = 0; i < targets.length; i++) {
if (!targets[i].classList.contains(CONFIG.HIDE_CLASS)) hidden = false;
}
if (!hidden) 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('