Загрузка данных
(function () {
'use strict';
var CAPTCHA_SITEKEY = 'ysc1_2AEEpAK4WRu0mf1BpGOdcfhiKbrfn4XHn5JpdXrR9c3006f7';
var API_BASE = '/fuel/qr';
var CLIENT_ID_KEY = 'fuelQrClientId';
var SESSION_UNTIL_KEY = 'fuelQrSessionUntil';
var PLATE_LATIN = 'ABEKMHOPCTYX';
var PLATE_CYRILLIC = 'АВЕКМНОРСТУХ';
var PLATE_STANDARD_RE = /^[АВЕКМНОРСТУХ][0-9]{3}[АВЕКМНОРСТУХ]{2}[0-9]{2,3}$/;
var PLATE_ALLOWED_RE = /^[0-9A-ZА-ЯЁ]{3,10}$/;
var state = {
authMode: '',
captchaScriptPromise: null,
maxScriptPromise: null,
widgetId: null,
clientId: null,
carPlate: '',
plateFormatConfirmed: false,
pendingPlateConfirmation: '',
fuelTypes: [],
outOfStockMessage: '',
currentTicket: null,
};
var els = {};
function qs(selector) {
return document.querySelector(selector);
}
function initElements() {
els.message = qs('[data-message]');
els.statusPill = qs('[data-status-pill]');
els.loadingTitle = qs('[data-loading-title]');
els.loadingText = qs('[data-loading-text]');
els.unsupportedTitle = qs('[data-unsupported-title]');
els.unsupportedText = qs('[data-unsupported-text]');
els.outOfStockMessage = qs('[data-out-of-stock-message]');
els.retryCaptcha = qs('[data-retry-captcha]');
els.plateForm = qs('[data-plate-form]');
els.plateInput = qs('[data-plate-input]');
els.plateSubmit = qs('[data-plate-submit]');
els.fuelForm = qs('[data-fuel-form]');
els.fuelSelect = qs('[data-fuel-select]');
els.createSubmit = qs('[data-create-submit]');
els.backToPlate = qs('[data-back-to-plate]');
els.ticket = qs('[data-ticket]');
els.ticketPlate = qs('[data-ticket-plate]');
els.ticketFuel = qs('[data-ticket-fuel]');
els.ticketQr = qs('[data-ticket-qr]');
els.ticketLink = qs('[data-ticket-link]');
els.newRequest = qs('[data-new-request]');
els.share = qs('[data-share]');
}
function showStep(name) {
Array.prototype.forEach.call(document.querySelectorAll('[data-step]'), function (node) {
node.classList.toggle('is-active', node.getAttribute('data-step') === name);
});
}
function setStatus(text) {
els.statusPill.textContent = text;
}
function showMessage(text, type) {
els.message.textContent = text;
els.message.className = 'notice notice--' + (type || 'info');
els.message.hidden = !text;
}
function clearMessage() {
showMessage('');
}
function debugBeacon(eventName, details) {
var url = API_BASE + '/clientapp-debug.gif'
+ '?event=' + encodeURIComponent(eventName || 'unknown')
+ '&location=' + encodeURIComponent(window.location.href)
+ '&auth_mode=' + encodeURIComponent(state.authMode || '')
+ '&client_id=' + encodeURIComponent(state.clientId || '')
+ '&details=' + encodeURIComponent(details || '')
+ '&_=' + Date.now();
var img = new Image();
img.src = url;
}
function setBusy(button, busy) {
if (!button) {
return;
}
button.disabled = !!busy;
button.setAttribute('aria-busy', busy ? 'true' : 'false');
button.classList.toggle('is-loading', !!busy);
}
function getStoredValue(key) {
try {
return window.localStorage.getItem(key) || '';
} catch (e) {
return '';
}
}
function setStoredValue(key, value) {
try {
window.localStorage.setItem(key, value);
} catch (e) {
// LocalStorage can be disabled in embedded webviews.
}
}
function removeStoredValue(key) {
try {
window.localStorage.removeItem(key);
} catch (e) {
// LocalStorage can be disabled in embedded webviews.
}
}
function isValidClientId(value) {
return /^[A-Za-z0-9._:-]{8,128}$/.test(value || '');
}
function readStoredClientId() {
var existing = getStoredValue(CLIENT_ID_KEY);
return isValidClientId(existing) ? existing : '';
}
function getStoredSessionUntil() {
var value = parseInt(getStoredValue(SESSION_UNTIL_KEY), 10);
return isFinite(value) ? value : 0;
}
function markSessionActive(ttl) {
var seconds = parseInt(ttl, 10);
if (!isFinite(seconds) || seconds <= 0) {
clearSessionMarker();
return;
}
setStoredValue(SESSION_UNTIL_KEY, String(Date.now() + seconds * 1000));
}
function clearSessionMarker() {
removeStoredValue(SESSION_UNTIL_KEY);
}
function shouldCheckSession() {
return isValidClientId(state.clientId) && getStoredSessionUntil() > Date.now();
}
function readAuthMode() {
var mode = '';
var rawMode = '';
try {
rawMode = new URLSearchParams(window.location.search).get('mode') || '';
} catch (e) {
rawMode = '';
}
mode = rawMode.split('?')[0].split('&')[0];
if (mode === '') {
debugBeacon('missing_mode_default_max');
return 'max-mini-app';
}
if (/^(web|max-mini-app|superapp)$/.test(mode)) {
if (mode !== rawMode) {
debugBeacon('mode_recovered_from_dirty_query', 'raw=' + rawMode + ';mode=' + mode);
}
return mode;
}
debugBeacon('invalid_mode', 'mode=' + rawMode);
return '';
}
function openForm() {
clearMessage();
showLoading('Проверяем остатки', 'Получаем доступные виды топлива.');
return loadFuelTypes(true).then(function (types) {
if (!types.length) {
showEmptyStock();
return false;
}
setStatus('Форма');
showStep('plate');
els.plateInput.focus();
return true;
}).catch(function (err) {
if (err && err.sessionExpired) {
startAuthFlow();
return false;
}
setStatus('Ошибка');
debugBeacon('open_form_failed', err.message || '');
showUnsupported('Не удалось открыть форму', 'Не удалось получить данные для выбора топлива.', err.message || 'Не удалось получить данные для выбора топлива.');
return false;
});
}
function showEmptyStock() {
clearMessage();
setStatus('Нет топлива');
if (els.outOfStockMessage) {
els.outOfStockMessage.textContent = state.outOfStockMessage;
}
showStep('empty-stock');
}
function showUnsupported(title, text, message) {
setStatus('Недоступно');
if (els.unsupportedTitle) {
els.unsupportedTitle.textContent = title || 'Режим недоступен';
}
if (els.unsupportedText) {
els.unsupportedText.textContent = text || 'Откройте страницу другим способом.';
}
showStep('unsupported');
showMessage(message || text || title || 'Режим недоступен', 'error');
}
function getWebApp() {
return window.WebApp || null;
}
function loadMaxScript() {
if (getWebApp()) {
return Promise.resolve();
}
if (state.maxScriptPromise) {
return state.maxScriptPromise;
}
state.maxScriptPromise = new Promise(function (resolve, reject) {
var script = document.createElement('script');
var timer = window.setTimeout(function () {
state.maxScriptPromise = null;
reject(new Error('MAX Bridge не загрузился.'));
}, 10000);
script.src = 'https://st.max.ru/js/max-web-app.js';
script.async = true;
script.onload = function () {
window.clearTimeout(timer);
resolve();
};
script.onerror = function () {
window.clearTimeout(timer);
state.maxScriptPromise = null;
reject(new Error('MAX Bridge не загрузился.'));
};
document.head.appendChild(script);
});
return state.maxScriptPromise;
}
function showLoading(title, text) {
if (els.loadingTitle) {
els.loadingTitle.textContent = title || 'Проверяем сессию';
}
if (els.loadingText) {
els.loadingText.textContent = text || 'Это займет несколько секунд.';
}
showStep('loading');
}
function makeClientId() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
return window.crypto.randomUUID();
}
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
var bytes = new Uint8Array(16);
window.crypto.getRandomValues(bytes);
return Array.prototype.map.call(bytes, function (b) {
return ('0' + b.toString(16)).slice(-2);
}).join('');
}
return String(Date.now()) + '-' + String(Math.random()).slice(2);
}
function getClientId() {
var existing = readStoredClientId();
if (existing) {
return existing;
}
var created = makeClientId();
setStoredValue(CLIENT_ID_KEY, created);
return created;
}
function normalizePlate(value) {
var input = String(value || '').trim().toUpperCase();
var out = '';
var i;
var ch;
var idx;
for (i = 0; i < input.length; i += 1) {
ch = input.charAt(i);
idx = PLATE_LATIN.indexOf(ch);
out += idx >= 0 ? PLATE_CYRILLIC.charAt(idx) : ch;
}
return out.replace(/[^0-9A-ZА-ЯЁ]/g, '');
}
function isStandardPlate(value) {
return PLATE_STANDARD_RE.test(value || '');
}
function isAllowedPlate(value) {
return PLATE_ALLOWED_RE.test(value || '');
}
function formatDate(value) {
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value || '';
}
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function buildShareText(ticket) {
return [
'QR-код на топливо',
'Госномер: ' + ticket.car_plate,
'Топливо: ' + ticket.fuel_type_title,
].join('\n');
}
function isValidTicket(ticket) {
return !!(
ticket
&& ticket.qr_png_base64
);
}
function qrPngFile(ticket) {
var binary = window.atob(ticket.qr_png_base64);
var bytes = new Uint8Array(binary.length);
var i;
var blob;
for (i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
blob = new Blob([bytes], { type: 'image/png' });
if (typeof window.File !== 'function') {
return null;
}
return new File([blob], 'fuel-qr-' + ticket.car_plate + '.png', { type: 'image/png' });
}
function shareDataForTicket(ticket) {
var text = buildShareText(ticket);
var data = {
title: 'QR-код на топливо',
text: text,
url: ticket.deeplink,
};
var file;
try {
file = qrPngFile(ticket);
if (
file
&& typeof window.navigator.canShare === 'function'
&& window.navigator.canShare({ files: [file] })
) {
data.files = [file];
}
} catch (e) {
// File sharing support differs across mobile webviews; text and link sharing remains useful.
}
return data;
}
function updateShareButton() {
els.share.hidden = !(state.currentTicket && window.navigator.share);
}
function resetCaptcha() {
if (window.smartCaptcha && state.widgetId !== null) {
window.smartCaptcha.reset(state.widgetId);
}
if (els.retryCaptcha) {
els.retryCaptcha.hidden = true;
}
}
function request(path, options) {
var params = options || {};
params.headers = Object.assign({ 'Content-Type': 'application/json' }, params.headers || {});
params.credentials = 'same-origin';
return window.fetch(API_BASE + path, params)
.then(function (res) {
return res.json().catch(function () {
return { status: 'fail', message: 'Некорректный ответ сервера' };
}).then(function (json) {
if (res.status === 401 || (res.status === 403 && json.message && json.message.indexOf('Сессия') >= 0)) {
var expired = new Error(json.message || 'Сессия истекла');
expired.sessionExpired = true;
clearSessionMarker();
resetCaptcha();
throw expired;
}
if (!res.ok || json.status !== 'ok') {
throw new Error(json.message || 'Ошибка обработки запроса');
}
return json.data || {};
});
});
}
function startCaptchaSession(token) {
setStatus('Открытие сессии');
clearMessage();
showLoading('Открываем сессию', 'Проверяем результат капчи.');
return request('/session', {
method: 'POST',
body: JSON.stringify({
captcha_token: token,
client_id: state.clientId,
}),
}).then(function (data) {
markSessionActive(data.ttl);
resetCaptcha();
openForm();
}).catch(function (err) {
clearSessionMarker();
resetCaptcha();
showCaptchaGate();
showMessage(err.message, 'error');
if (els.retryCaptcha) {
els.retryCaptcha.hidden = false;
}
});
}
function showCaptchaGate() {
setStatus('Проверка');
showStep('captcha');
loadCaptchaScript().then(onCaptchaLoad).catch(function () {
showMessage('Не удалось загрузить проверку. Проверьте соединение.', 'error');
if (els.retryCaptcha) {
els.retryCaptcha.hidden = false;
}
});
}
function loadCaptchaScript() {
if (window.__fuelCaptchaReady) {
return Promise.resolve();
}
if (state.captchaScriptPromise) {
return state.captchaScriptPromise;
}
window.onFuelCaptchaLoad = function () {
window.__fuelCaptchaReady = true;
if (window.FuelQrPage) {
window.FuelQrPage.onCaptchaLoad();
}
};
state.captchaScriptPromise = new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = 'https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=onFuelCaptchaLoad';
script.async = true;
script.defer = true;
script.onload = function () {
resolve();
};
script.onerror = function () {
state.captchaScriptPromise = null;
reject(new Error('captcha load failed'));
};
document.head.appendChild(script);
});
return state.captchaScriptPromise;
}
function checkSessionStatus() {
setStatus('Проверка');
showLoading('Проверяем сессию', 'Если проверка еще действует, форма откроется автоматически.');
return request('/session/status', { method: 'GET', headers: {} }).then(function (data) {
if ((data.auth_mode || 'web') !== state.authMode) {
debugBeacon('session_auth_mode_mismatch', 'server=' + (data.auth_mode || 'web') + ';client=' + state.authMode);
clearSessionMarker();
startAuthFlow();
return;
}
markSessionActive(data.ttl);
openForm();
}).catch(function (err) {
clearSessionMarker();
clearMessage();
if (err && err.sessionExpired) {
startAuthFlow();
return;
}
startAuthFlow();
});
}
function startMaxMiniAppSession() {
setStatus('MAX');
clearMessage();
showLoading('Проверяем доступ', 'Ожидаем данные MAX.');
loadMaxScript().then(function () {
var webApp = getWebApp();
if (!webApp) {
debugBeacon('max_webapp_missing');
throw new Error('Откройте страницу внутри MAX.');
}
if (!webApp.initData) {
debugBeacon('max_init_data_missing');
throw new Error('Откройте страницу внутри MAX.');
}
if (typeof webApp.requestContact !== 'function') {
debugBeacon('max_request_contact_missing');
throw new Error('Откройте страницу внутри MAX.');
}
showLoading('Проверяем доступ', 'Запрашиваем номер телефона.');
return Promise.resolve(webApp.requestContact()).then(function (result) {
return {
webApp: webApp,
result: result,
};
});
}).then(function (context) {
var result = context.result;
if (result && result.error) {
debugBeacon('max_contact_denied_or_failed', result.error);
throw new Error('Для доступа нужно разрешить передачу номера телефона.');
}
var contact = result && result.contact ? result.contact : result;
return request('/session/max', {
method: 'POST',
body: JSON.stringify({
client_id: state.clientId,
init_data: context.webApp.initData,
contact: contact,
platform: context.webApp.platform,
version: context.webApp.version,
}),
});
}).then(function (data) {
markSessionActive(data.ttl);
openForm();
}).catch(function (err) {
clearSessionMarker();
debugBeacon('max_session_failed', err.message || '');
showUnsupported('Не удалось открыть MAX-режим', err.message || 'Повторите попытку позже.', err.message || 'Не удалось открыть MAX-режим.');
});
}
function startUnsupportedSuperApp() {
showUnsupported('Режим приложения не поддержан', 'Авторизация superapp будет добавлена позже.', 'Режим приложения пока не поддержан.');
}
function startAuthFlow() {
if (state.authMode === 'web') {
showCaptchaGate();
return;
}
if (state.authMode === 'max-mini-app') {
startMaxMiniAppSession();
return;
}
if (state.authMode === 'superapp') {
startUnsupportedSuperApp();
return;
}
debugBeacon('auth_flow_unhandled_mode', state.authMode || '');
showUnsupported('Не удалось выбрать сценарий авторизации', 'Режим страницы определен, но для него не найден обработчик.', 'Не найден обработчик режима: ' + (state.authMode || 'пусто') + '.');
}
function onCaptchaLoad() {
var container = document.getElementById('captcha-container');
if (state.widgetId !== null) {
return;
}
if (!window.smartCaptcha || !container) {
return;
}
state.widgetId = window.smartCaptcha.render(container, {
sitekey: CAPTCHA_SITEKEY,
hl: 'ru',
callback: startCaptchaSession,
});
window.smartCaptcha.subscribe(state.widgetId, 'network-error', function () {
showMessage('Не удалось загрузить проверку. Проверьте соединение.', 'error');
els.retryCaptcha.hidden = false;
});
window.smartCaptcha.subscribe(state.widgetId, 'javascript-error', function () {
showMessage('Проверка временно недоступна. Обновите страницу.', 'error');
els.retryCaptcha.hidden = false;
});
window.smartCaptcha.subscribe(state.widgetId, 'token-expired', function () {
resetCaptcha();
showMessage('Время проверки истекло. Пройдите ее заново.', 'error');
els.retryCaptcha.hidden = false;
});
}
function loadFuelTypes(force) {
if (!force && state.fuelTypes.length) {
return Promise.resolve(state.fuelTypes);
}
return request('/fuel-types', { method: 'GET', headers: {} }).then(function (data) {
state.fuelTypes = data.fuel_types || [];
state.outOfStockMessage = data.out_of_stock_message || '';
return state.fuelTypes;
});
}
function fillFuelTypes(types) {
els.fuelSelect.innerHTML = '';
var placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = 'Выберите';
placeholder.selected = true;
placeholder.disabled = true;
els.fuelSelect.appendChild(placeholder);
types.forEach(function (item) {
var option = document.createElement('option');
option.value = item.id;
option.textContent = item.title;
els.fuelSelect.appendChild(option);
});
}
function renderTicket(ticket, message) {
if (!isValidTicket(ticket)) {
state.currentTicket = null;
updateShareButton();
showMessage('Не удалось получить данные QR-кода. Попробуйте позже.', 'error');
setStatus('Ошибка');
return;
}
state.currentTicket = ticket;
els.ticketPlate.textContent = ticket.car_plate;
els.ticketFuel.textContent = ticket.fuel_type_title;
els.ticketQr.src = 'data:image/png;base64,' + ticket.qr_png_base64;
els.ticketLink.href = ticket.deeplink;
els.ticket.hidden = false;
updateShareButton();
showMessage(message || 'QR-код готов.', ticket.reused ? 'info' : 'success');
setStatus('QR-код');
showStep('result');
}
function onPlateSubmit(event) {
event.preventDefault();
clearMessage();
if (!state.fuelTypes.length) {
showEmptyStock();
return;
}
var normalized = normalizePlate(els.plateInput.value);
els.plateInput.value = normalized;
if (!isAllowedPlate(normalized)) {
state.pendingPlateConfirmation = '';
state.plateFormatConfirmed = false;
showMessage('Недопустимый формат госномера. Укажите от 3 до 10 букв или цифр.', 'error');
return;
}
var isStandard = isStandardPlate(normalized);
if (!isStandard && state.pendingPlateConfirmation !== normalized) {
state.pendingPlateConfirmation = normalized;
state.plateFormatConfirmed = false;
showMessage('Номер ' + normalized + ' не похож на стандартный российский госномер. Если номер указан верно, нажмите «Проверить номер» ещё раз.', 'warning');
return;
}
state.carPlate = normalized;
state.plateFormatConfirmed = !isStandard;
state.pendingPlateConfirmation = '';
setBusy(els.plateSubmit, true);
request('/plate/check', {
method: 'POST',
body: JSON.stringify({
car_plate: normalized,
plate_format_confirmed: state.plateFormatConfirmed,
}),
}).then(function (data) {
if ((data.state === 'active' || data.state === 'active_own') && data.ticket) {
renderTicket(data.ticket, 'По этому госномеру уже есть активный QR-код.');
return;
}
if (data.state === 'blocked') {
showMessage('Новый QR-код по этому госномеру будет доступен: ' + formatDate(data.next_create_at), 'error');
return;
}
fillFuelTypes(state.fuelTypes);
setStatus('Топливо');
showStep('fuel');
}).catch(function (err) {
if (err && err.sessionExpired) {
startAuthFlow();
return;
}
showMessage(err.message, 'error');
}).finally(function () {
setBusy(els.plateSubmit, false);
});
}
function onFuelSubmit(event) {
event.preventDefault();
clearMessage();
if (!state.fuelTypes.length) {
showEmptyStock();
return;
}
if (!els.fuelSelect.value) {
showMessage('Выберите вид топлива.', 'error');
els.fuelSelect.focus();
return;
}
setBusy(els.createSubmit, true);
request('/create', {
method: 'POST',
body: JSON.stringify({
car_plate: state.carPlate,
fuel_type_id: els.fuelSelect.value,
plate_format_confirmed: state.plateFormatConfirmed,
}),
}).then(function (data) {
renderTicket(data.ticket, data.state === 'active' || data.state === 'active_own' ? 'По этому госномеру уже есть активный QR-код.' : 'QR-код готов.');
}).catch(function (err) {
if (err && err.sessionExpired) {
startAuthFlow();
return;
}
showMessage(err.message, 'error');
}).finally(function () {
setBusy(els.createSubmit, false);
});
}
function bindEvents() {
els.retryCaptcha.addEventListener('click', function () {
clearMessage();
els.retryCaptcha.hidden = true;
resetCaptcha();
if (!window.__fuelCaptchaReady) {
showCaptchaGate();
}
});
els.plateForm.addEventListener('submit', onPlateSubmit);
els.fuelForm.addEventListener('submit', onFuelSubmit);
els.backToPlate.addEventListener('click', function () {
clearMessage();
els.fuelSelect.value = '';
setStatus('Госномер');
showStep('plate');
els.plateInput.focus();
});
els.newRequest.addEventListener('click', function () {
clearMessage();
state.carPlate = '';
state.plateFormatConfirmed = false;
state.pendingPlateConfirmation = '';
state.currentTicket = null;
els.ticket.hidden = true;
updateShareButton();
els.plateInput.value = '';
openForm();
});
els.share.addEventListener('click', function () {
if (!isValidTicket(state.currentTicket) || !window.navigator.share) {
return;
}
setBusy(els.share, true);
window.navigator.share(shareDataForTicket(state.currentTicket)).catch(function (err) {
if (err && err.name === 'AbortError') {
return;
}
showMessage('Не удалось открыть меню отправки. Скопируйте ссылку на QR-код.', 'error');
}).finally(function () {
setBusy(els.share, false);
});
});
}
function init() {
initElements();
state.clientId = readStoredClientId() || getClientId();
state.authMode = readAuthMode();
bindEvents();
updateShareButton();
if (!state.authMode) {
showUnsupported('Некорректный режим открытия', 'Некорректный параметр в адресе.', 'Некорректный параметр mode.');
return;
}
if (shouldCheckSession()) {
checkSessionStatus();
return;
}
clearSessionMarker();
startAuthFlow();
}
window.FuelQrPage = {
onCaptchaLoad: onCaptchaLoad,
};
if (window.__fuelCaptchaReady) {
onCaptchaLoad();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());