версия 1
/**
* 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);
}
})();
Версия 2
/**
* GetCourse Offer Tag Manager
* Скрипт для управления обязательными тегами офферов
*
* Формат тега: TYPE:PRODUCT:CAT (например: SPR:MK:P)
*/
(function() {
'use strict';
// ========================================
// КОНФИГУРАЦИЯ
// ========================================
const Config = {
// Google Sheets
spreadsheetId: '1JM5CUl8xm9sB8CyvyVZCXZyxbjjO2z2jEP7Sp1h7Zqs',
sheets: ['TYPE', 'PRODUCT', 'CAT'],
// Google Apps Script Web App URL для добавления значений
appsScriptUrl: 'https://script.google.com/macros/s/AKfycbyQH69gioBwoEEm58NTET4tsMB2UKcbiWOcrvStl_ufHCMdeKQLs4Q7L1zM00D8z-goTw/exec',
// Формат обязательного тега
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: 'Обязательный тег оффера'
},
addValue: {
title: 'Добавить новое значение',
code: 'Код (заглавные буквы/цифры)',
label: 'Название',
description: 'Описание (необязательно)',
submit: 'Добавить',
cancel: 'Отмена',
success: 'Значение успешно добавлено',
error: 'Ошибка при добавлении значения',
invalidCode: 'Код должен содержать только заглавные буквы и цифры',
duplicateCode: 'Такой код уже существует'
}
}
};
// ========================================
// ЗАГРУЗЧИК ДАННЫХ
// ========================================
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
};
}
};
// ========================================
// SEARCHABLE SELECT КОМПОНЕНТ
// ========================================
const SearchableSelect = {
/**
* Создать searchable dropdown вместо обычного select
*/
create(id, options, placeholder) {
const $wrapper = $('<div>')
.addClass('searchable-select')
.attr('data-id', id);
const $input = $('<input>')
.attr('type', 'text')
.attr('id', id)
.attr('placeholder', placeholder)
.attr('autocomplete', 'off')
.addClass('form-control searchable-input')
.css('fontFamily', 'monospace');
const $hidden = $('<input>')
.attr('type', 'hidden')
.addClass('searchable-value');
const $dropdown = $('<div>')
.addClass('searchable-dropdown');
// Создать опции
for (const item of options) {
const $option = $('<div>')
.addClass('searchable-option')
.attr('data-value', item.code)
.attr('data-code', item.code.toLowerCase())
.attr('data-label', item.label.toLowerCase())
.attr('title', item.description || '')
.text(`${item.code} — ${item.label}`);
$dropdown.append($option);
}
$wrapper.append($input).append($hidden).append($dropdown);
// Привязка событий
this._bindEvents($wrapper);
return $wrapper;
},
/**
* Привязка событий к компоненту
*/
_bindEvents($wrapper) {
const $input = $wrapper.find('.searchable-input');
const $dropdown = $wrapper.find('.searchable-dropdown');
const $options = $dropdown.find('.searchable-option');
const self = this;
let activeIndex = -1;
// Фокус — показать dropdown
$input.on('focus', function() {
self._showAllOptions($wrapper);
$dropdown.show();
activeIndex = -1;
});
// Ввод текста — фильтрация
$input.on('input', function() {
const query = $(this).val().toLowerCase().trim();
self._filterOptions($wrapper, query);
activeIndex = -1;
self._updateActiveOption($wrapper, activeIndex);
});
// Клавиатурная навигация
$input.on('keydown', function(e) {
const $visible = $dropdown.find('.searchable-option:not(.hidden)');
const visibleCount = $visible.length;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (visibleCount > 0) {
activeIndex = (activeIndex + 1) % visibleCount;
self._updateActiveOption($wrapper, activeIndex);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (visibleCount > 0) {
activeIndex = activeIndex <= 0 ? visibleCount - 1 : activeIndex - 1;
self._updateActiveOption($wrapper, activeIndex);
}
} else if (e.key === 'Enter') {
e.preventDefault();
const $active = $dropdown.find('.searchable-option.active');
if ($active.length) {
self._selectOption($wrapper, $active);
}
} else if (e.key === 'Escape') {
$dropdown.hide();
$input.blur();
}
});
// Клик на опцию
$dropdown.on('click', '.searchable-option', function() {
self._selectOption($wrapper, $(this));
});
// Hover на опцию
$dropdown.on('mouseenter', '.searchable-option', function() {
$options.removeClass('active');
$(this).addClass('active');
activeIndex = $dropdown.find('.searchable-option:not(.hidden)').index($(this));
});
// Клик вне — закрыть dropdown
$(document).on('click', function(e) {
if (!$wrapper.is(e.target) && $wrapper.has(e.target).length === 0) {
$dropdown.hide();
}
});
},
/**
* Показать все опции (сбросить фильтр)
*/
_showAllOptions($wrapper) {
$wrapper.find('.searchable-option').removeClass('hidden active');
},
/**
* Фильтрация опций по запросу
*/
_filterOptions($wrapper, query) {
const $options = $wrapper.find('.searchable-option');
if (!query) {
$options.removeClass('hidden');
return;
}
$options.each(function() {
const $opt = $(this);
const code = $opt.attr('data-code');
const label = $opt.attr('data-label');
// Поиск по коду И по label
if (code.includes(query) || label.includes(query)) {
$opt.removeClass('hidden');
} else {
$opt.addClass('hidden');
}
});
},
/**
* Обновить активную опцию (подсветка)
*/
_updateActiveOption($wrapper, index) {
const $visible = $wrapper.find('.searchable-option:not(.hidden)');
$visible.removeClass('active');
if (index >= 0 && index < $visible.length) {
const $active = $visible.eq(index);
$active.addClass('active');
// Прокрутка к активной опции
const $dropdown = $wrapper.find('.searchable-dropdown');
const optionTop = $active.position().top;
const optionHeight = $active.outerHeight();
const dropdownHeight = $dropdown.height();
const scrollTop = $dropdown.scrollTop();
if (optionTop < 0) {
$dropdown.scrollTop(scrollTop + optionTop);
} else if (optionTop + optionHeight > dropdownHeight) {
$dropdown.scrollTop(scrollTop + optionTop + optionHeight - dropdownHeight);
}
}
},
/**
* Выбрать опцию
*/
_selectOption($wrapper, $option) {
const value = $option.attr('data-value');
const text = $option.text();
$wrapper.find('.searchable-input').val(text);
$wrapper.find('.searchable-value').val(value);
$wrapper.find('.searchable-dropdown').hide();
// Trigger change event для обновления превью
$wrapper.trigger('searchable:change');
},
/**
* Получить выбранное значение
*/
getValue(id) {
const $wrapper = $(`.searchable-select[data-id="${id}"]`);
return $wrapper.find('.searchable-value').val() || '';
},
/**
* Установить значение программно
*/
setValue(id, value) {
const $wrapper = $(`.searchable-select[data-id="${id}"]`);
if (!$wrapper.length) return;
const $option = $wrapper.find(`.searchable-option[data-value="${value}"]`);
if ($option.length) {
$wrapper.find('.searchable-input').val($option.text());
$wrapper.find('.searchable-value').val(value);
} else {
$wrapper.find('.searchable-input').val('');
$wrapper.find('.searchable-value').val('');
}
},
/**
* Добавить новую опцию в dropdown
*/
addOption(id, item) {
const $wrapper = $(`.searchable-select[data-id="${id}"]`);
if (!$wrapper.length) return;
const $dropdown = $wrapper.find('.searchable-dropdown');
const $option = $('<div>')
.addClass('searchable-option')
.attr('data-value', item.code)
.attr('data-code', item.code.toLowerCase())
.attr('data-label', item.label.toLowerCase())
.attr('title', item.description || '')
.text(`${item.code} — ${item.label}`);
$dropdown.append($option);
// Автоматически выбрать новую опцию
this._selectOption($wrapper, $option);
}
};
// ========================================
// МОДАЛЬНОЕ ОКНО ДОБАВЛЕНИЯ ЗНАЧЕНИЯ
// ========================================
const AddValueModal = {
$modal: null,
currentSheet: null,
onSuccess: null,
/**
* Инициализация модального окна
*/
init() {
if (this.$modal) return;
const msg = Config.messages.addValue;
const modalHtml = `
<div id="add-value-modal" class="add-value-modal-overlay" style="display: none;">
<div class="add-value-modal">
<div class="add-value-modal-header">
<h4>${msg.title}: <span id="add-value-sheet-name"></span></h4>
<button type="button" class="add-value-modal-close">×</button>
</div>
<div class="add-value-modal-body">
<div class="form-group">
<label for="add-value-code">${msg.code} *</label>
<input type="text" id="add-value-code" class="form-control"
pattern="[A-Z0-9]+" style="text-transform: uppercase; font-family: monospace;">
</div>
<div class="form-group">
<label for="add-value-label">${msg.label} *</label>
<input type="text" id="add-value-label" class="form-control">
</div>
<div class="form-group">
<label for="add-value-description">${msg.description}</label>
<input type="text" id="add-value-description" class="form-control">
</div>
<div id="add-value-error" class="alert alert-danger" style="display: none;"></div>
</div>
<div class="add-value-modal-footer">
<button type="button" class="btn btn-default add-value-cancel">${msg.cancel}</button>
<button type="button" class="btn btn-primary add-value-submit">${msg.submit}</button>
</div>
</div>
</div>
`;
$('body').append(modalHtml);
this.$modal = $('#add-value-modal');
this._bindEvents();
},
/**
* Привязка событий
*/
_bindEvents() {
const self = this;
// Закрытие модального окна
this.$modal.on('click', '.add-value-modal-close, .add-value-cancel', function() {
self.hide();
});
// Клик на overlay закрывает
this.$modal.on('click', function(e) {
if ($(e.target).hasClass('add-value-modal-overlay')) {
self.hide();
}
});
// Escape закрывает
$(document).on('keydown', function(e) {
if (e.key === 'Escape' && self.$modal.is(':visible')) {
self.hide();
}
});
// Отправка формы
this.$modal.on('click', '.add-value-submit', function() {
self._submit();
});
// Enter в полях отправляет форму
this.$modal.on('keydown', 'input', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
self._submit();
}
});
// Автоматический uppercase для кода
$('#add-value-code').on('input', function() {
$(this).val($(this).val().toUpperCase().replace(/[^A-Z0-9]/g, ''));
});
},
/**
* Показать модальное окно
*/
show(sheetName, onSuccess) {
this.init();
this.currentSheet = sheetName;
this.onSuccess = onSuccess;
// Название листа
const sheetLabels = {
'TYPE': Config.messages.labels.type,
'PRODUCT': Config.messages.labels.product,
'CAT': Config.messages.labels.cat
};
$('#add-value-sheet-name').text(sheetLabels[sheetName] || sheetName);
// Очистка полей
$('#add-value-code').val('');
$('#add-value-label').val('');
$('#add-value-description').val('');
$('#add-value-error').hide();
this.$modal.fadeIn(200);
$('#add-value-code').focus();
},
/**
* Скрыть модальное окно
*/
hide() {
this.$modal.fadeOut(200);
this.currentSheet = null;
this.onSuccess = null;
},
/**
* Показать ошибку
*/
_showError(message) {
$('#add-value-error').text(message).show();
},
/**
* Отправка данных
*/
async _submit() {
const code = $('#add-value-code').val().trim();
const label = $('#add-value-label').val().trim();
const description = $('#add-value-description').val().trim();
// Валидация
if (!code) {
this._showError(Config.messages.addValue.invalidCode);
$('#add-value-code').focus();
return;
}
if (!/^[A-Z0-9]+$/.test(code)) {
this._showError(Config.messages.addValue.invalidCode);
$('#add-value-code').focus();
return;
}
if (!label) {
this._showError('Введите название');
$('#add-value-label').focus();
return;
}
// Проверка на дубликат
const existingCodes = DataLoader.cache[this.currentSheet]?.map(item => item.code) || [];
if (existingCodes.includes(code)) {
this._showError(Config.messages.addValue.duplicateCode);
$('#add-value-code').focus();
return;
}
// Блокировка кнопки
const $submitBtn = this.$modal.find('.add-value-submit');
$submitBtn.prop('disabled', true).text('Добавление...');
try {
await this._sendToAppsScript({
sheet: this.currentSheet,
code: code,
label: label,
description: description
});
// Добавляем в локальный кэш
const newItem = { code, label, description };
if (DataLoader.cache[this.currentSheet]) {
DataLoader.cache[this.currentSheet].push(newItem);
}
// Обновляем known values в TagParser
if (TagParser.knownValues[this.currentSheet]) {
TagParser.knownValues[this.currentSheet].push(code);
// Перестраиваем strict pattern
TagParser.setKnownValues(DataLoader.cache);
}
// Callback
if (this.onSuccess) {
this.onSuccess(newItem);
}
this.hide();
console.log('[OfferTagManager] Value added:', newItem);
} catch (error) {
console.error('[OfferTagManager] Error adding value:', error);
this._showError(Config.messages.addValue.error + ': ' + error.message);
} finally {
$submitBtn.prop('disabled', false).text(Config.messages.addValue.submit);
}
},
/**
* Отправка в Google Apps Script
*/
async _sendToAppsScript(data) {
if (Config.appsScriptUrl === 'YOUR_APPS_SCRIPT_WEB_APP_URL') {
throw new Error('Apps Script URL не настроен. См. инструкцию в INSTALL.md');
}
const response = await fetch(Config.appsScriptUrl, {
method: 'POST',
mode: 'no-cors', // Apps Script требует no-cors для POST
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
// no-cors не возвращает тело ответа, считаем успехом если нет исключения
return true;
}
};
// ========================================
// ЧТЕНИЕ ТЕГОВ ИЗ 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;
},
/**
* Создать колонку с searchable select
*/
_createSelectColumn(name, label, options) {
const selectId = `mandatory-tag-${name}`;
const sheetName = name.toUpperCase();
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 $inputWrapper = $('<div>')
.addClass('searchable-select-wrapper')
.css({ display: 'flex', gap: '6px', alignItems: 'flex-start' });
// Использовать SearchableSelect вместо обычного select
const $searchable = SearchableSelect.create(
selectId,
options,
Config.messages.selectPlaceholder
);
$searchable.addClass(Config.ui.selectClass).attr('data-param', name);
$searchable.css('flex', '1');
// Кнопка добавления нового значения
const $addBtn = $('<button>')
.attr('type', 'button')
.addClass('btn btn-default add-value-btn')
.attr('data-sheet', sheetName)
.attr('title', 'Добавить новое значение')
.html('+')
.css({
padding: '6px 10px',
fontSize: '16px',
fontWeight: 'bold',
lineHeight: '1',
minWidth: '34px',
height: '34px'
});
$inputWrapper.append($searchable).append($addBtn);
$group.append($inputWrapper);
$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;
SearchableSelect.setValue('mandatory-tag-type', parsed.type);
SearchableSelect.setValue('mandatory-tag-product', parsed.product);
SearchableSelect.setValue('mandatory-tag-cat', parsed.cat);
},
/**
* Получить значения из селектов
*/
getSelectValues() {
return {
type: SearchableSelect.getValue('mandatory-tag-type'),
product: SearchableSelect.getValue('mandatory-tag-product'),
cat: SearchableSelect.getValue('mandatory-tag-cat')
};
}
};
// ========================================
// ГЛАВНЫЙ МОДУЛЬ
// ========================================
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;
// Изменение searchable selects
$(`.${Config.ui.selectClass}`).on('searchable:change', function() {
self._onSelectChange();
});
// Кнопка копирования тега
$('#copy-mandatory-tag').on('click', function() {
UIBuilder.copyTagToClipboard();
});
// Кнопки добавления новых значений
$('.add-value-btn').on('click', function() {
const sheetName = $(this).attr('data-sheet');
const selectId = `mandatory-tag-${sheetName.toLowerCase()}`;
AddValueModal.show(sheetName, function(newItem) {
// Добавить новую опцию в dropdown и выбрать её
SearchableSelect.addOption(selectId, newItem);
// Обновить превью тега
self._onSelectChange();
});
});
},
/**
* Обработчик изменения селекта
*/
_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">
/* Main Container */
#${Config.ui.containerId} {
border: none;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
#${Config.ui.containerId} .panel-heading {
background: #5bc0de;
border: none;
color: #fff;
padding: 14px 20px;
}
#${Config.ui.containerId} .panel-heading strong {
font-size: 14px;
letter-spacing: 0.3px;
}
#${Config.ui.containerId} .panel-body {
background: #fafbfc;
padding: 20px;
}
#${Config.ui.containerId} .form-group label {
font-weight: 600;
color: #374151;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
/* Status Alert */
#mandatory-tag-status {
border: none;
border-radius: 6px;
font-size: 13px;
padding: 12px 16px;
}
#mandatory-tag-status.alert-success {
background: #dff0d8;
color: #3c763d;
}
#mandatory-tag-status.alert-warning {
background: #fcf8e3;
color: #8a6d3b;
}
#mandatory-tag-status.alert-danger {
background: #f2dede;
color: #a94442;
}
/* Tag Preview */
#mandatory-tag-preview {
background: #f5f5f5 !important;
border: 2px dashed #dee2e6 !important;
border-radius: 6px !important;
font-size: 16px !important;
letter-spacing: 1px;
}
#copy-mandatory-tag {
border-radius: 6px !important;
}
#copy-mandatory-tag:hover {
background: rgba(91, 192, 222, 0.1) !important;
}
#copy-mandatory-tag:hover svg {
stroke: #5bc0de;
}
#copy-feedback {
color: #5bc0de !important;
font-weight: 500;
}
/* Searchable Select */
.searchable-select {
position: relative;
}
.searchable-select .searchable-input {
cursor: pointer;
background: #fff;
border: 2px solid #e5e7eb;
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.searchable-select .searchable-input:hover {
border-color: #d1d5db;
}
.searchable-select .searchable-input:focus {
cursor: text;
border-color: #5bc0de;
outline: none;
}
.searchable-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 1000;
max-height: 350px;
overflow-y: auto;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: #fff;
display: none;
box-shadow: 0 10px 40px rgba(0,0,0,0.12);
}
.searchable-dropdown::-webkit-scrollbar {
width: 6px;
}
.searchable-dropdown::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.searchable-dropdown::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.searchable-dropdown::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
.searchable-option {
padding: 10px 14px;
cursor: pointer;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
transition: background-color 0.1s ease;
}
.searchable-option:last-child {
border-bottom: none;
}
.searchable-option:hover {
background: #f9fafb;
}
.searchable-option.active {
background: #5bc0de;
color: #fff;
}
.searchable-option.hidden {
display: none;
}
/* Add Value Button */
.add-value-btn {
background: #5cb85c;
border: none;
color: #fff;
border-radius: 6px;
font-weight: 600;
}
.add-value-btn:hover {
background: #449d44;
color: #fff;
}
/* Modal Overlay */
.add-value-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(17, 24, 39, 0.6);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
/* Modal */
.add-value-modal {
background: #fff;
border-radius: 12px;
width: 100%;
max-width: 420px;
margin: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.add-value-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px;
background: #5bc0de;
}
.add-value-modal-header h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #fff;
}
.add-value-modal-close {
background: rgba(255,255,255,0.2);
border: none;
width: 28px;
height: 28px;
border-radius: 6px;
font-size: 18px;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.add-value-modal-close:hover {
background: rgba(255,255,255,0.3);
}
.add-value-modal-body {
padding: 24px;
}
.add-value-modal-body .form-group {
margin-bottom: 18px;
}
.add-value-modal-body .form-group:last-of-type {
margin-bottom: 0;
}
.add-value-modal-body label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #374151;
}
.add-value-modal-body .form-control {
border: 2px solid #e5e7eb;
border-radius: 6px;
padding: 10px 12px;
font-size: 14px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.add-value-modal-body .form-control:focus {
border-color: #5bc0de;
outline: none;
}
.add-value-modal-body .alert {
border: none;
border-radius: 6px;
margin-top: 16px;
font-size: 13px;
}
.add-value-modal-footer {
padding: 16px 24px;
background: #f9fafb;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.add-value-modal-footer .btn {
padding: 10px 20px;
border-radius: 6px;
font-weight: 500;
font-size: 14px;
transition: all 0.15s ease;
}
.add-value-modal-footer .btn-default {
background: #fff;
border: 2px solid #e5e7eb;
color: #374151;
}
.add-value-modal-footer .btn-default:hover {
background: #f9fafb;
border-color: #d1d5db;
}
.add-value-modal-footer .btn-primary {
background: #5bc0de;
border: none;
color: #fff;
}
.add-value-modal-footer .btn-primary:hover {
background: #46b8da;
}
.add-value-modal-footer .btn-primary:disabled {
opacity: 0.6;
}
</style>
`;
$('head').append(styles);
}
};
// ========================================
// АВТОЗАПУСК
// ========================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() { OfferTagManager.init(); }, 100);
});
} else {
setTimeout(function() { OfferTagManager.init(); }, 100);
}
})();
/**
* Google Apps Script для добавления значений в таблицу тегов
*
* ИНСТРУКЦИЯ ПО УСТАНОВКЕ:
* 1. Откройте Google Sheets с данными тегов
* 2. Меню: Расширения → Apps Script
* 3. Скопируйте этот код в редактор
* 4. Нажмите "Развернуть" → "Новое развертывание"
* 5. Тип: "Веб-приложение"
* 6. Выполнять как: "Я"
* 7. Доступ: "Все" (для работы с GetCourse)
* 8. Нажмите "Развернуть"
* 9. Скопируйте URL веб-приложения
* 10. Вставьте URL в Config.appsScriptUrl в offer-tag-manager.js
*/
/**
* Обработчик POST-запросов
*/
function doPost(e) {
try {
const data = JSON.parse(e.postData.contents);
const sheet = data.sheet;
const code = data.code;
const label = data.label;
const description = data.description || '';
// Валидация
if (!sheet || !code || !label) {
return createResponse(false, 'Missing required fields');
}
if (!/^[A-Z0-9]+$/.test(code)) {
return createResponse(false, 'Invalid code format');
}
// Получить лист
const ss = SpreadsheetApp.getActiveSpreadsheet();
const targetSheet = ss.getSheetByName(sheet);
if (!targetSheet) {
return createResponse(false, 'Sheet not found: ' + sheet);
}
// Проверить на дубликат
const existingData = targetSheet.getDataRange().getValues();
for (let i = 1; i < existingData.length; i++) {
if (existingData[i][0] === code) {
return createResponse(false, 'Code already exists');
}
}
// Добавить новую строку
targetSheet.appendRow([code, label, description]);
return createResponse(true, 'Value added successfully');
} catch (error) {
return createResponse(false, error.message);
}
}
/**
* Обработчик GET-запросов (для тестирования)
*/
function doGet(e) {
return createResponse(true, 'Apps Script is working');
}
/**
* Создание ответа
*/
function createResponse(success, message) {
const response = {
success: success,
message: message
};
return ContentService
.createTextOutput(JSON.stringify(response))
.setMimeType(ContentService.MimeType.JSON);
}