add-tag
/**
* GetCourse Offer Tag Manager
* Скрипт для управления обязательными тегами офферов
*
* Формат тега: TYPE:PRODUCT:CAT (например: SPR:MK:P)
*/
(function() {
'use strict';
// ========================================
// КОНФИГУРАЦИЯ
// ========================================
const Config = {
// Google Sheets
spreadsheetId: '1JM5CUl8xm9sB8CyvyVZCXZyxbjjO2z2jEP7Sp1h7Zqs',
sheets: ['TYPE', 'PRODUCT', 'CAT'],
// Формат обязательного тега
tagFormat: {
separator: ':',
// Базовый паттерн: 3 части через двоеточие, заглавные буквы/цифры
pattern: /^[A-Z0-9]+:[A-Z0-9]+:[A-Z]$/,
// Строгий паттерн с известными значениями (будет обновлен после загрузки данных)
strictPattern: null
},
// Селекторы GetCourse
selectors: {
offerForm: '#offerForm',
headerWithTags: '.header-with-tags',
offerUpdate: '.offer-update',
saveButton: '.save-offer',
copyButton: '.copy-offer',
// Селекторы для чтения тегов
tagsContainer: '.gc-tags-editable',
tagsInputName: 'Offer[tags][]'
},
// UI настройки
ui: {
containerId: 'mandatory-tag-container',
selectClass: 'mandatory-tag-select'
},
// Сообщения
messages: {
selectPlaceholder: '-- Выберите --',
missingParams: 'Выберите все параметры обязательного тега',
validTag: 'Тег сформирован',
copyHint: 'Скопируйте и вставьте тег в список тегов оффера',
missingTag: 'Обязательный тег отсутствует в списке тегов оффера',
invalidTag: 'Обязательный тег имеет неверный формат',
multipleTags: 'Найдено несколько обязательных тегов. Должен быть ровно один.',
tagFound: 'Обязательный тег найден в списке',
labels: {
type: 'Тип',
product: 'Продукт',
cat: 'Категория',
preview: 'Тег',
title: 'Обязательный тег оффера'
}
}
};
// ========================================
// ЗАГРУЗЧИК ДАННЫХ
// ========================================
const DataLoader = {
cache: {},
/**
* URL для Google Visualization Query API
*/
buildUrl(sheetName) {
return `https://docs.google.com/spreadsheets/d/${Config.spreadsheetId}/gviz/tq?tqx=out:json&sheet=${encodeURIComponent(sheetName)}`;
},
/**
* Парсинг ответа Google Visualization API
*/
parseGvizResponse(responseText) {
try {
// Удаляем JSONP обертку: google.visualization.Query.setResponse({...});
const jsonStr = responseText
.replace(/^[^(]*\(/, '')
.replace(/\);?\s*$/, '');
const data = JSON.parse(jsonStr);
const rows = data.table.rows || [];
// Пропускаем первую строку (заголовки)
const dataRows = rows.slice(1);
return dataRows.map(row => {
const cells = row.c || [];
return {
code: cells[0]?.v?.toString() || '',
label: cells[1]?.v?.toString() || cells[0]?.v?.toString() || '',
description: cells[2]?.v?.toString() || ''
};
}).filter(item => item.code && item.code.trim());
} catch (e) {
console.error('[OfferTagManager] Parse error:', e);
return [];
}
},
/**
* Загрузить один лист
*/
async loadSheet(sheetName) {
if (this.cache[sheetName]) {
return this.cache[sheetName];
}
const url = this.buildUrl(sheetName);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for sheet ${sheetName}`);
}
const text = await response.text();
const data = this.parseGvizResponse(text);
if (data.length === 0) {
throw new Error(`Empty data for sheet ${sheetName}`);
}
this.cache[sheetName] = data;
return data;
},
/**
* Загрузить все листы параллельно
*/
async loadAllSheets() {
const results = await Promise.all(
Config.sheets.map(sheet =>
this.loadSheet(sheet)
.then(data => ({ sheet, data, error: null }))
.catch(error => ({ sheet, data: [], error }))
)
);
const sheetsData = {};
const errors = [];
for (const result of results) {
if (result.error) {
errors.push(`${result.sheet}: ${result.error.message}`);
} else {
sheetsData[result.sheet] = result.data;
}
}
if (errors.length > 0) {
throw new Error(errors.join('; '));
}
return sheetsData;
}
};
// ========================================
// ПАРСЕР ТЕГОВ
// ========================================
const TagParser = {
knownValues: {
TYPE: [],
PRODUCT: [],
CAT: []
},
/**
* Установить известные значения из загруженных данных
*/
setKnownValues(sheetsData) {
this.knownValues.TYPE = sheetsData.TYPE.map(item => item.code);
this.knownValues.PRODUCT = sheetsData.PRODUCT.map(item => item.code);
this.knownValues.CAT = sheetsData.CAT.map(item => item.code);
// Построить строгий regex
const typePattern = this.knownValues.TYPE.join('|');
const productPattern = this.knownValues.PRODUCT.join('|');
const catPattern = this.knownValues.CAT.join('|');
Config.tagFormat.strictPattern = new RegExp(
`^(${typePattern}):(${productPattern}):(${catPattern})$`
);
},
/**
* Проверить, похож ли тег на обязательный (по формату)
*/
isMandatoryTagFormat(tag) {
return Config.tagFormat.pattern.test(tag);
},
/**
* Строгая проверка тега (известные значения)
*/
isValidMandatoryTag(tag) {
if (!Config.tagFormat.strictPattern) {
return this.isMandatoryTagFormat(tag);
}
return Config.tagFormat.strictPattern.test(tag);
},
/**
* Разобрать тег на компоненты
*/
parseTag(tag) {
if (!this.isMandatoryTagFormat(tag)) {
return null;
}
const parts = tag.split(Config.tagFormat.separator);
return {
type: parts[0],
product: parts[1],
cat: parts[2]
};
},
/**
* Собрать тег из компонентов
*/
buildTag(type, product, cat) {
if (!type || !product || !cat) return null;
return [type, product, cat].join(Config.tagFormat.separator);
},
/**
* Найти обязательные теги в массиве
*/
findMandatoryTags(tags) {
const mandatory = tags.filter(tag => {
const matches = this.isMandatoryTagFormat(tag);
console.log(`[OfferTagManager] Tag "${tag}" matches pattern: ${matches}`);
return matches;
});
return {
tags: mandatory,
count: mandatory.length,
first: mandatory[0] || null
};
}
};
// ========================================
// ЧТЕНИЕ ТЕГОВ ИЗ GC-TAGS-EDITABLE
// ========================================
const TagReader = {
$container: null,
/**
* Инициализация
*/
init() {
this.$container = $(Config.selectors.tagsContainer).first();
return this.$container.length > 0;
},
/**
* Получить текущие теги
*/
getCurrentTags() {
const tags = [];
// Способ 1: из hidden inputs (самый надёжный)
$(Config.selectors.tagsContainer)
.find(`input[name="${Config.selectors.tagsInputName}"]`)
.each(function() {
const val = $(this).val();
if (val && val.trim()) {
tags.push(val.trim());
}
});
if (tags.length > 0) {
console.log('[OfferTagManager] Found tags from inputs:', tags);
return tags;
}
// Способ 2: из data-атрибута
const dataTags = $(Config.selectors.tagsContainer).attr('data-tags');
if (dataTags) {
const parsed = dataTags.split(',').map(t => t.trim()).filter(Boolean);
console.log('[OfferTagManager] Found tags from data-tags:', parsed);
return parsed;
}
console.log('[OfferTagManager] No tags found');
return [];
},
/**
* Найти обязательные теги в списке
*/
findMandatoryTags() {
const allTags = this.getCurrentTags();
const result = TagParser.findMandatoryTags(allTags);
console.log('[OfferTagManager] Mandatory tags check:', result);
return result;
}
};
// ========================================
// ВАЛИДАЦИЯ
// ========================================
const Validation = {
$saveButton: null,
$copyButton: null,
/**
* Инициализация
*/
init() {
this.$saveButton = $(Config.selectors.saveButton);
this.$copyButton = $(Config.selectors.copyButton);
this._interceptFormSubmit();
this._setupTagsObserver();
},
/**
* Перехват отправки формы
*/
_interceptFormSubmit() {
const $form = $(Config.selectors.offerForm);
const self = this;
$form.on('submit', function(e) {
const result = self.validate();
if (!result.valid) {
e.preventDefault();
e.stopImmediatePropagation();
UIBuilder.updateStatus(result.error, 'danger');
// Прокрутка к блоку
const $container = $(`#${Config.ui.containerId}`);
if ($container.length) {
$('html, body').animate({
scrollTop: $container.offset().top - 100
}, 300);
}
alert(result.error);
return false;
}
});
},
/**
* Наблюдатель за изменениями в контейнере тегов
*/
_setupTagsObserver() {
const $tagsContainer = $(Config.selectors.tagsContainer);
if (!$tagsContainer.length) return;
const self = this;
const observer = new MutationObserver(() => {
console.log('[OfferTagManager] Tags changed, re-validating...');
self.updateButtonsState();
});
observer.observe($tagsContainer[0], {
attributes: true,
childList: true,
subtree: true,
characterData: true
});
},
/**
* Валидация
*/
validate() {
const mandatory = TagReader.findMandatoryTags();
// Проверка 1: тег должен быть
if (mandatory.count === 0) {
return { valid: false, error: Config.messages.missingTag };
}
// Проверка 2: ровно один тег
if (mandatory.count > 1) {
return { valid: false, error: Config.messages.multipleTags };
}
// Проверка 3: тег валиден (из известных значений)
if (!TagParser.isValidMandatoryTag(mandatory.first)) {
return { valid: false, error: Config.messages.invalidTag };
}
return { valid: true, error: null, tag: mandatory.first };
},
/**
* Обновить состояние кнопок
*/
updateButtonsState() {
const result = this.validate();
if (result.valid) {
this.$saveButton.prop('disabled', false);
this.$copyButton.prop('disabled', false);
UIBuilder.updateStatus(Config.messages.tagFound + ': ' + result.tag, 'success');
} else {
this.$saveButton.prop('disabled', true);
this.$copyButton.prop('disabled', true);
UIBuilder.updateStatus(result.error, 'danger');
}
}
};
// ========================================
// ПОСТРОЕНИЕ UI
// ========================================
const UIBuilder = {
/**
* Создать основной контейнер
*/
createTagSelector(sheetsData) {
const $container = $('<div>')
.attr('id', Config.ui.containerId)
.addClass('panel panel-info')
.css({
marginTop: '15px',
marginBottom: '15px'
});
// Заголовок панели
const $heading = $('<div>')
.addClass('panel-heading')
.html(`<strong>${Config.messages.labels.title}</strong>`);
// Тело панели
const $body = $('<div>')
.addClass('panel-body')
.css('paddingBottom', '10px');
// Строка с селектами
const $row = $('<div>').addClass('row');
// TYPE селект
$row.append(this._createSelectColumn('type', Config.messages.labels.type, sheetsData.TYPE));
// PRODUCT селект
$row.append(this._createSelectColumn('product', Config.messages.labels.product, sheetsData.PRODUCT));
// CAT селект
$row.append(this._createSelectColumn('cat', Config.messages.labels.cat, sheetsData.CAT));
// Превью тега с кнопкой копирования
const $preview = $('<div>')
.addClass('col-md-3 col-sm-6')
.html(`
<div class="form-group" style="margin-bottom: 0">
<label>${Config.messages.labels.preview}</label>
<div style="display: flex; align-items: center; gap: 8px;">
<div id="mandatory-tag-preview"
style="font-family: monospace; font-weight: bold; font-size: 15px;
padding: 7px 12px; min-height: 34px; color: #999;
border: 1px dashed #ccc; border-radius: 4px; background: #f9f9f9;">
—
</div>
<button type="button" id="copy-mandatory-tag"
style="display: none; border: none; background: none; cursor: pointer;
padding: 6px 10px; border-radius: 4px; transition: all 0.2s ease;"
title="Скопировать тег">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#5cb85c" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div id="copy-feedback" style="display: none; color: #5cb85c; font-size: 11px; margin-top: 4px;">
Скопировано
</div>
</div>
`);
$row.append($preview);
$body.append($row);
// Статус
const $status = $('<div>')
.attr('id', 'mandatory-tag-status')
.addClass('alert alert-warning')
.css({ marginTop: '10px', marginBottom: '0' })
.text(Config.messages.missingParams);
$body.append($status);
$container.append($heading).append($body);
return $container;
},
/**
* Создать колонку с селектом
*/
_createSelectColumn(name, label, options) {
const selectId = `mandatory-tag-${name}`;
const $col = $('<div>').addClass('col-md-3 col-sm-6');
const $group = $('<div>')
.addClass('form-group')
.css('marginBottom', '0');
$group.append(
$('<label>')
.attr('for', selectId)
.text(label)
);
const $select = $('<select>')
.attr('id', selectId)
.addClass(`form-control ${Config.ui.selectClass}`)
.attr('data-param', name)
.attr('data-placeholder', Config.messages.selectPlaceholder)
.css('fontFamily', 'monospace');
// Пустая опция (для Select2 placeholder)
$select.append(
$('<option>')
.val('')
.text('')
);
// Опции из данных
for (const item of options) {
$select.append(
$('<option>')
.val(item.code)
.text(`${item.code} — ${item.label}`)
.attr('title', item.description || '')
);
}
$group.append($select);
$col.append($group);
return $col;
},
/**
* Обновить превью тега
*/
updatePreview(tag) {
const $preview = $('#mandatory-tag-preview');
const $copyBtn = $('#copy-mandatory-tag');
if (tag) {
$preview.text(tag).css('color', '#3c763d');
$copyBtn.show();
} else {
$preview.text('—').css('color', '#999');
$copyBtn.hide();
}
// Скрыть feedback при изменении
$('#copy-feedback').hide();
},
/**
* Копировать тег в буфер обмена
*/
copyTagToClipboard() {
const tag = $('#mandatory-tag-preview').text();
if (!tag || tag === '—') return;
navigator.clipboard.writeText(tag).then(() => {
// Показать feedback
const $feedback = $('#copy-feedback');
$feedback.show();
// Скрыть через 2 секунды
setTimeout(() => {
$feedback.fadeOut();
}, 2000);
console.log('[OfferTagManager] Tag copied:', tag);
}).catch(err => {
// Fallback для старых браузеров
const textArea = document.createElement('textarea');
textArea.value = tag;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
$('#copy-feedback').show();
setTimeout(() => {
$('#copy-feedback').fadeOut();
}, 2000);
} catch (e) {
alert('Не удалось скопировать. Выделите тег вручную и нажмите Ctrl+C');
}
document.body.removeChild(textArea);
});
},
/**
* Обновить статус
*/
updateStatus(message, type) {
const $status = $('#mandatory-tag-status');
$status
.removeClass('alert-success alert-warning alert-danger alert-info')
.addClass(`alert-${type}`)
.text(message);
},
/**
* Заполнить селекты из тега
*/
fillSelectsFromTag(parsed) {
if (!parsed) return;
$('#mandatory-tag-type').val(parsed.type);
$('#mandatory-tag-product').val(parsed.product);
$('#mandatory-tag-cat').val(parsed.cat);
},
/**
* Получить значения из селектов
*/
getSelectValues() {
return {
type: $('#mandatory-tag-type').val(),
product: $('#mandatory-tag-product').val(),
cat: $('#mandatory-tag-cat').val()
};
}
};
// ========================================
// ГЛАВНЫЙ МОДУЛЬ
// ========================================
const OfferTagManager = {
initialized: false,
sheetsData: null,
currentMandatoryTag: null,
/**
* Проверка страницы
*/
isOfferEditPage() {
return /\/pl\/sales\/offer\/update\?id=\d+/.test(window.location.href);
},
/**
* Точка входа
*/
async init() {
if (!this.isOfferEditPage()) {
return;
}
console.log('[OfferTagManager] Initializing...');
try {
await this._waitForDependencies();
// Загрузка данных
this.sheetsData = await DataLoader.loadAllSheets();
console.log('[OfferTagManager] Data loaded:', this.sheetsData);
// Установить известные значения
TagParser.setKnownValues(this.sheetsData);
// Построить UI
this._buildUI();
// События
this._bindEvents();
// Валидация
Validation.init();
Validation.updateButtonsState();
// Стили
this._injectStyles();
this.initialized = true;
console.log('[OfferTagManager] Initialized successfully');
} catch (error) {
console.error('[OfferTagManager] Initialization failed:', error);
this._showError(error.message);
}
},
/**
* Ожидание зависимостей
*/
_waitForDependencies() {
return new Promise((resolve, reject) => {
let attempts = 0;
const maxAttempts = 100; // 10 секунд
const check = () => {
attempts++;
if (typeof jQuery !== 'undefined' &&
jQuery(Config.selectors.offerForm).length > 0) {
resolve();
return;
}
if (attempts >= maxAttempts) {
reject(new Error('Timeout waiting for page elements'));
return;
}
setTimeout(check, 100);
};
check();
});
},
/**
* Построение UI
*/
_buildUI() {
const $selector = UIBuilder.createTagSelector(this.sheetsData);
// Вставка: после .header-with-tags или перед формой
const $header = $(Config.selectors.headerWithTags);
const $offerUpdate = $(Config.selectors.offerUpdate);
if ($header.length) {
$header.after($selector);
} else if ($offerUpdate.length) {
$offerUpdate.find('h1').first().after($selector);
} else {
$(Config.selectors.offerForm).before($selector);
}
},
/**
* Привязка событий
*/
_bindEvents() {
const self = this;
// Инициализация Select2 для поиска в выпадающих списках
if (typeof $.fn.select2 !== 'undefined') {
$(`.${Config.ui.selectClass}`).select2({
placeholder: Config.messages.selectPlaceholder,
allowClear: true,
width: '100%',
language: {
noResults: function() { return "Ничего не найдено"; },
searching: function() { return "Поиск..."; }
}
});
console.log('[OfferTagManager] Select2 initialized');
} else {
console.warn('[OfferTagManager] Select2 not available, using standard selects');
}
// Изменение селектов (работает и с Select2, и без)
$(`.${Config.ui.selectClass}`).on('change', function() {
self._onSelectChange();
});
// Кнопка копирования тега
$('#copy-mandatory-tag').on('click', function() {
UIBuilder.copyTagToClipboard();
});
},
/**
* Обработчик изменения селекта
*/
_onSelectChange() {
const values = UIBuilder.getSelectValues();
if (values.type && values.product && values.cat) {
const newTag = TagParser.buildTag(values.type, values.product, values.cat);
this.currentMandatoryTag = newTag;
UIBuilder.updatePreview(newTag);
UIBuilder.updateStatus(Config.messages.validTag + '. ' + Config.messages.copyHint, 'success');
} else {
this.currentMandatoryTag = null;
UIBuilder.updatePreview(null);
UIBuilder.updateStatus(Config.messages.missingParams, 'warning');
}
},
/**
* Показать ошибку
*/
_showError(message) {
const $error = $('<div>')
.addClass('alert alert-danger')
.css({ margin: '15px 0' })
.html(`
<strong>Ошибка загрузки:</strong> ${message}<br>
<small>Попробуйте обновить страницу. Если проблема сохраняется, обратитесь в поддержку.</small>
`);
const $header = $(Config.selectors.headerWithTags);
if ($header.length) {
$header.after($error);
} else {
$(Config.selectors.offerForm).before($error);
}
// Заблокировать сохранение при ошибке загрузки
$(Config.selectors.saveButton).prop('disabled', true);
$(Config.selectors.copyButton).prop('disabled', true);
},
/**
* Внедрение стилей
*/
_injectStyles() {
if ($('#offer-tag-manager-styles').length) return;
const styles = `
<style id="offer-tag-manager-styles">
#${Config.ui.containerId} {
border-color: #5bc0de;
}
#${Config.ui.containerId} .panel-heading {
background-color: #d9edf7;
border-color: #5bc0de;
color: #31708f;
}
#${Config.ui.containerId} .form-group label {
font-weight: 600;
color: #555;
}
#${Config.ui.containerId} select {
font-size: 13px;
}
#mandatory-tag-status {
transition: all 0.2s ease;
}
#copy-mandatory-tag:hover {
background: rgba(92, 184, 92, 0.1) !important;
}
#copy-mandatory-tag:hover svg {
stroke: #449d44;
}
#copy-mandatory-tag:active {
transform: scale(0.95);
}
/* Select2 стили для панели */
#${Config.ui.containerId} .select2-container {
width: 100% !important;
}
#${Config.ui.containerId} .select2-container--default .select2-selection--single {
height: 34px;
border: 1px solid #ccc;
border-radius: 4px;
}
#${Config.ui.containerId} .select2-container--default .select2-selection--single .select2-selection__rendered {
line-height: 32px;
font-family: monospace;
font-size: 13px;
}
#${Config.ui.containerId} .select2-container--default .select2-selection--single .select2-selection__arrow {
height: 32px;
}
#${Config.ui.containerId} .select2-container--default .select2-selection--single .select2-selection__placeholder {
color: #999;
}
.select2-container--default .select2-results__option {
font-family: monospace;
font-size: 13px;
}
.select2-container--default .select2-results__option--highlighted[aria-selected] {
background-color: #5bc0de;
}
.select2-container--default .select2-search--dropdown .select2-search__field {
font-size: 13px;
}
</style>
`;
$('head').append(styles);
}
};
// ========================================
// АВТОЗАПУСК
// ========================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() { OfferTagManager.init(); }, 100);
});
} else {
setTimeout(function() { OfferTagManager.init(); }, 100);
}
})();