Чтобы перейти к оплате, отметьте все обязательные согласия.
'
].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('