Создан переиспользуемый компонент поиска товаров

- product_search_picker.html - универсальный шаблон компонента поиска и выбора товаров
- product-search-picker.js - JavaScript модуль с поддержкой фильтрации по категориям, тегам, наличию
- product-search-picker.css - стили для компонента
- Поддержка одиночного и множественного выбора товаров
- Фильтрация по категориям, тегам и наличию на складе
- Отображение фото товара в результатах поиска
- Адаптивный интерфейс с прокруткой для больших списков
- API для программного управления (init, search, clearSelection и др.)
- Возможность кастомизации через параметры (заголовок, высота, текст кнопок)
This commit is contained in:
2025-12-10 23:36:13 +03:00
parent 96e04ca4b7
commit ccab09fb40
3 changed files with 1099 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
/**
* ProductSearchPicker - стили компонента поиска и выбора товаров
*/
.product-search-picker .product-picker-item {
transition: all 0.15s ease;
border: 2px solid transparent;
}
.product-search-picker .product-picker-item:hover {
border-color: #dee2e6;
background-color: #f8f9fa;
}
.product-search-picker .product-picker-item.selected {
border-color: #198754 !important;
background-color: #d1e7dd !important;
}
.product-search-picker .product-picker-item.selected .card {
border-color: #198754;
}
/* Grid view - карточки */
.product-search-picker .product-picker-grid[data-view="grid"] {
display: flex;
flex-wrap: wrap;
}
.product-search-picker .product-picker-grid[data-view="grid"] .product-picker-item .card {
transition: transform 0.15s, box-shadow 0.15s;
}
.product-search-picker .product-picker-grid[data-view="grid"] .product-picker-item:hover .card {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.product-search-picker .product-picker-grid[data-view="grid"] .product-picker-item.selected .card {
box-shadow: 0 0 0 2px #198754;
}
/* List view - список */
.product-search-picker .product-picker-grid[data-view="list"] {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.product-search-picker .product-picker-grid[data-view="list"] .col-6,
.product-search-picker .product-picker-grid[data-view="list"] .col-lg-4,
.product-search-picker .product-picker-grid[data-view="list"] .col-xl-3 {
flex: 0 0 100%;
max-width: 100%;
padding: 0;
}
/* Loading spinner */
.product-search-picker .product-picker-loading {
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
/* Контент - scrollable */
.product-search-picker .product-picker-content {
position: relative;
min-height: 150px;
}
/* Фильтры */
.product-search-picker .product-picker-filters {
background-color: #f8f9fa;
}
/* Счетчик выбранных */
.product-search-picker .product-picker-selected-count {
min-width: 24px;
text-align: center;
}
/* Иконка выбора в list view */
.product-search-picker .product-picker-grid[data-view="list"] .bi-circle,
.product-search-picker .product-picker-grid[data-view="list"] .bi-check-circle-fill {
font-size: 1.2rem;
flex-shrink: 0;
}
/* Иконка выбора в grid view */
.product-search-picker .product-picker-grid[data-view="grid"] .check-icon {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-size: 1.5rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
z-index: 1;
}
/* Пустой результат */
.product-search-picker .product-picker-empty {
min-height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Кнопка очистки поиска */
.product-search-picker .product-picker-search-clear {
border-left: 0;
}
/* Hover эффект для кнопок действий */
.product-search-picker .card-footer .btn {
transition: all 0.15s;
}
/* Адаптивность */
@media (max-width: 768px) {
.product-search-picker .product-picker-grid[data-view="grid"] .col-6 {
flex: 0 0 50%;
max-width: 50%;
}
.product-search-picker .card-header .d-flex {
flex-direction: column;
align-items: stretch !important;
gap: 0.5rem;
}
.product-search-picker .card-header .input-group {
width: 100% !important;
}
.product-search-picker .card-footer {
flex-direction: column;
gap: 0.5rem;
}
.product-search-picker .card-footer > div {
width: 100%;
display: flex;
gap: 0.5rem;
}
.product-search-picker .card-footer .btn {
flex: 1;
}
}
@media (max-width: 576px) {
.product-search-picker .product-picker-grid[data-view="grid"] .col-6 {
flex: 0 0 100%;
max-width: 100%;
}
.product-search-picker .product-picker-filters .row {
flex-direction: column;
}
.product-search-picker .product-picker-filters .col-auto {
width: 100%;
}
.product-search-picker .product-picker-filters .col-auto.ms-auto {
margin-left: 0 !important;
margin-top: 0.5rem;
}
}

View File

@@ -0,0 +1,747 @@
/**
* ProductSearchPicker - переиспользуемый модуль для поиска и выбора товаров
*
* API:
* - ProductSearchPicker.init(containerSelector, options) - инициализация
* - instance.getSelected() - получить выбранные товары (массив объектов)
* - instance.getSelectedIds() - получить ID выбранных товаров (массив строк)
* - instance.clearSelection() - сбросить выбор
* - instance.setSelection(productIds) - установить выбор программно
* - instance.refresh() - обновить список товаров
* - instance.destroy() - уничтожить экземпляр
*
* Пример использования:
* const picker = ProductSearchPicker.init('#my-picker', {
* onAddSelected: function(products, instance) {
* console.log('Добавлены товары:', products);
* }
* });
*/
(function(window) {
'use strict';
// Приватное хранилище экземпляров
var instances = {};
/**
* Конструктор компонента
*/
function ProductSearchPicker(container, options) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
console.error('ProductSearchPicker: container not found');
return;
}
// Настройки по умолчанию
this.options = Object.assign({
apiUrl: this.container.dataset.apiUrl || '/products/api/search-products-variants/',
multiSelect: this.container.dataset.multiSelect !== 'false',
maxSelection: parseInt(this.container.dataset.maxSelection) || null,
excludeKits: this.container.dataset.excludeKits !== 'false',
debounceDelay: 300,
pageSize: 30,
// Callbacks
onSelect: null, // (products, instance) => {}
onDeselect: null, // (products, instance) => {}
onSelectionChange: null, // (selectedProducts, instance) => {}
onAddSelected: null // (selectedProducts, instance) => {}
}, options || {});
// Состояние
this.state = {
products: [],
selected: {}, // {productId: productData}
currentPage: 1,
hasMore: false,
isLoading: false,
currentView: 'list',
filters: {
search: '',
category: '',
tag: '',
inStock: false
}
};
// Кэшируем DOM элементы
this._cacheElements();
// Привязываем обработчики
this._bindEvents();
// Начальная загрузка
this._loadProducts();
}
/**
* Кэширование DOM элементов
*/
ProductSearchPicker.prototype._cacheElements = function() {
var c = this.container;
this.elements = {
searchInput: c.querySelector('.product-picker-search'),
searchClear: c.querySelector('.product-picker-search-clear'),
categorySelect: c.querySelector('.product-picker-category'),
tagSelect: c.querySelector('.product-picker-tag'),
inStockCheckbox: c.querySelector('.product-picker-in-stock'),
grid: c.querySelector('.product-picker-grid'),
loading: c.querySelector('.product-picker-loading'),
empty: c.querySelector('.product-picker-empty'),
content: c.querySelector('.product-picker-content'),
selectedCount: c.querySelector('.product-picker-selected-count'),
selectAllBtn: c.querySelector('.product-picker-select-all'),
clearSelectionBtn: c.querySelector('.product-picker-clear-selection'),
addSelectedBtn: c.querySelector('.product-picker-add-selected'),
viewButtons: c.querySelectorAll('.product-picker-view')
};
// Начальный вид из data-атрибута
if (this.elements.grid) {
var initialView = this.elements.grid.dataset.view || 'list';
this.state.currentView = initialView;
this._updateViewButtons();
}
};
/**
* Привязка обработчиков событий
*/
ProductSearchPicker.prototype._bindEvents = function() {
var self = this;
var debounceTimer;
// Поиск с debounce
if (this.elements.searchInput) {
this.elements.searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
var value = this.value.trim();
// Показать/скрыть кнопку очистки
if (self.elements.searchClear) {
self.elements.searchClear.style.display = value ? 'block' : 'none';
}
debounceTimer = setTimeout(function() {
self.state.filters.search = value;
self.state.currentPage = 1;
self._loadProducts();
}, self.options.debounceDelay);
});
// Поиск по Enter
this.elements.searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(debounceTimer);
self.state.filters.search = this.value.trim();
self.state.currentPage = 1;
self._loadProducts();
}
});
}
// Кнопка очистки поиска
if (this.elements.searchClear) {
this.elements.searchClear.addEventListener('click', function() {
self.elements.searchInput.value = '';
this.style.display = 'none';
self.state.filters.search = '';
self.state.currentPage = 1;
self._loadProducts();
});
}
// Фильтр по категории
if (this.elements.categorySelect) {
this.elements.categorySelect.addEventListener('change', function() {
self.state.filters.category = this.value;
self.state.currentPage = 1;
self._loadProducts();
});
}
// Фильтр по тегу
if (this.elements.tagSelect) {
this.elements.tagSelect.addEventListener('change', function() {
self.state.filters.tag = this.value;
self.state.currentPage = 1;
self._loadProducts();
});
}
// Фильтр по наличию
if (this.elements.inStockCheckbox) {
this.elements.inStockCheckbox.addEventListener('change', function() {
self.state.filters.inStock = this.checked;
self.state.currentPage = 1;
self._loadProducts();
});
// Инициализация из checkbox
this.state.filters.inStock = this.elements.inStockCheckbox.checked;
}
// Переключатель вида
this.elements.viewButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var view = this.dataset.view;
self._setView(view);
});
});
// Клик по товару (делегирование)
if (this.elements.grid) {
this.elements.grid.addEventListener('click', function(e) {
var productCard = e.target.closest('.product-picker-item');
if (productCard) {
var productId = productCard.dataset.productId;
self._toggleProduct(productId);
}
});
}
// Выбрать все
if (this.elements.selectAllBtn) {
this.elements.selectAllBtn.addEventListener('click', function() {
self._selectAll();
});
}
// Сбросить выбор
if (this.elements.clearSelectionBtn) {
this.elements.clearSelectionBtn.addEventListener('click', function() {
self.clearSelection();
});
}
// Добавить выбранные
if (this.elements.addSelectedBtn) {
this.elements.addSelectedBtn.addEventListener('click', function() {
if (self.options.onAddSelected) {
self.options.onAddSelected(self.getSelected(), self);
}
});
}
// Infinite scroll
if (this.elements.content) {
this.elements.content.addEventListener('scroll', function() {
if (self.state.hasMore && !self.state.isLoading) {
var scrollTop = this.scrollTop;
var scrollHeight = this.scrollHeight;
var clientHeight = this.clientHeight;
if (scrollTop + clientHeight >= scrollHeight - 100) {
self.state.currentPage++;
self._loadProducts(true); // append mode
}
}
});
}
};
/**
* Загрузка товаров через AJAX
*/
ProductSearchPicker.prototype._loadProducts = function(append) {
var self = this;
if (this.state.isLoading) return;
this.state.isLoading = true;
// Показать индикатор загрузки
if (!append) {
if (this.elements.loading) this.elements.loading.style.display = 'flex';
if (this.elements.grid) this.elements.grid.innerHTML = '';
if (this.elements.empty) this.elements.empty.style.display = 'none';
}
// Формируем параметры запроса
var params = new URLSearchParams({
type: 'product', // Только штучные товары
page: this.state.currentPage
});
if (this.state.filters.search) {
params.append('q', this.state.filters.search);
}
if (this.state.filters.category) {
params.append('category', this.state.filters.category);
}
if (this.state.filters.tag) {
params.append('tag', this.state.filters.tag);
}
if (this.state.filters.inStock) {
params.append('in_stock', 'true');
}
fetch(this.options.apiUrl + '?' + params.toString())
.then(function(response) {
return response.json();
})
.then(function(data) {
self.state.isLoading = false;
if (self.elements.loading) self.elements.loading.style.display = 'none';
// Извлекаем товары (учитываем группировку в API)
var products = [];
if (Array.isArray(data.results)) {
data.results.forEach(function(item) {
if (item.children) {
// Группированные результаты
item.children.forEach(function(p) {
if (p.type === 'product') {
products.push(p);
}
});
} else if (item.type === 'product') {
products.push(item);
}
});
}
if (append) {
self.state.products = self.state.products.concat(products);
} else {
self.state.products = products;
}
self.state.hasMore = data.pagination && data.pagination.more;
// Рендеринг
self._renderProducts(products, append);
// Показать "пусто" если нет результатов
if (self.state.products.length === 0) {
if (self.elements.empty) self.elements.empty.style.display = 'flex';
} else {
if (self.elements.empty) self.elements.empty.style.display = 'none';
}
})
.catch(function(error) {
console.error('ProductSearchPicker: fetch error', error);
self.state.isLoading = false;
if (self.elements.loading) self.elements.loading.style.display = 'none';
});
};
/**
* Рендеринг товаров
*/
ProductSearchPicker.prototype._renderProducts = function(products, append) {
var self = this;
var fragment = document.createDocumentFragment();
products.forEach(function(product) {
// Извлекаем ID (убираем префикс "product_")
var productId = String(product.id).replace('product_', '');
var isSelected = self.state.selected.hasOwnProperty(productId);
var html = self.state.currentView === 'grid'
? self._renderGridCard(product, productId, isSelected)
: self._renderListItem(product, productId, isSelected);
var temp = document.createElement('div');
temp.innerHTML = html;
fragment.appendChild(temp.firstElementChild);
});
if (append) {
this.elements.grid.appendChild(fragment);
} else {
this.elements.grid.innerHTML = '';
this.elements.grid.appendChild(fragment);
}
};
/**
* Рендеринг карточки товара (grid view)
*/
ProductSearchPicker.prototype._renderGridCard = function(product, productId, isSelected) {
var inStockBadge = product.in_stock
? '<span class="badge bg-success" style="font-size: 0.65rem;">В наличии</span>'
: '<span class="badge bg-secondary" style="font-size: 0.65rem;">Нет</span>';
var selectedClass = isSelected ? 'selected' : '';
var checkIcon = isSelected
? '<i class="bi bi-check-circle-fill text-success check-icon"></i>'
: '';
var photoHtml = product.photo_url
? '<img src="' + product.photo_url + '" class="card-img-top" style="height: 100px; object-fit: cover;" alt="' + (product.text || '') + '">'
: '<div class="bg-light d-flex align-items-center justify-content-center" style="height: 100px;"><i class="bi bi-image text-muted fs-2"></i></div>';
var name = product.text || product.name || '';
var price = product.actual_price || product.price || '0';
return '<div class="col-6 col-lg-4 col-xl-3">' +
'<div class="card product-picker-item h-100 shadow-sm ' + selectedClass + '" ' +
'data-product-id="' + productId + '" style="cursor: pointer;">' +
'<div class="position-relative">' +
photoHtml +
checkIcon +
'</div>' +
'<div class="card-body p-2">' +
'<div class="small text-truncate fw-medium">' + name + '</div>' +
'<div class="d-flex justify-content-between align-items-center mt-1">' +
'<span class="fw-bold text-primary small">' + price + ' руб.</span>' +
inStockBadge +
'</div>' +
'<small class="text-muted">' + (product.sku || '') + '</small>' +
'</div>' +
'</div>' +
'</div>';
};
/**
* Рендеринг строки товара (list view)
*/
ProductSearchPicker.prototype._renderListItem = function(product, productId, isSelected) {
var inStockBadge = product.in_stock
? '<span class="badge bg-success">В наличии</span>'
: '<span class="badge bg-secondary">Нет</span>';
var selectedClass = isSelected ? 'selected' : '';
var checkIcon = isSelected
? '<i class="bi bi-check-circle-fill text-success me-2"></i>'
: '<i class="bi bi-circle text-muted me-2"></i>';
var photoHtml = product.photo_url
? '<img src="' + product.photo_url + '" class="rounded" style="width: 40px; height: 40px; object-fit: cover;">'
: '<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;"><i class="bi bi-image text-muted"></i></div>';
var name = product.text || product.name || '';
var price = product.actual_price || product.price || '0';
return '<div class="col-12">' +
'<div class="product-picker-item d-flex align-items-center p-2 border rounded ' + selectedClass + '" ' +
'data-product-id="' + productId + '" style="cursor: pointer;">' +
checkIcon +
'<div class="flex-shrink-0 me-2">' +
photoHtml +
'</div>' +
'<div class="flex-grow-1" style="min-width: 0;">' +
'<div class="text-truncate fw-medium">' + name + '</div>' +
'<small class="text-muted">' + (product.sku || '') + '</small>' +
'</div>' +
'<div class="text-end ms-2 text-nowrap">' +
'<div class="fw-bold text-primary">' + price + ' руб.</div>' +
inStockBadge +
'</div>' +
'</div>' +
'</div>';
};
/**
* Переключение выбора товара
*/
ProductSearchPicker.prototype._toggleProduct = function(productId) {
var self = this;
var product = null;
// Находим товар в списке
for (var i = 0; i < this.state.products.length; i++) {
var p = this.state.products[i];
if (String(p.id).replace('product_', '') === productId) {
product = p;
break;
}
}
if (!product) return;
var isSelected = this.state.selected.hasOwnProperty(productId);
if (isSelected) {
// Снять выбор
delete this.state.selected[productId];
if (this.options.onDeselect) {
this.options.onDeselect([product], this);
}
} else {
// Проверка множественного выбора
if (!this.options.multiSelect) {
this.state.selected = {};
}
// Проверка лимита
if (this.options.maxSelection &&
Object.keys(this.state.selected).length >= this.options.maxSelection) {
return;
}
// Добавить в выбор
this.state.selected[productId] = product;
if (this.options.onSelect) {
this.options.onSelect([product], this);
}
}
// Обновить UI
this._updateProductUI(productId);
this._updateSelectionUI();
// Общий callback
if (this.options.onSelectionChange) {
this.options.onSelectionChange(this.getSelected(), this);
}
};
/**
* Обновление UI товара
*/
ProductSearchPicker.prototype._updateProductUI = function(productId) {
var card = this.elements.grid.querySelector('[data-product-id="' + productId + '"]');
if (!card) return;
var isSelected = this.state.selected.hasOwnProperty(productId);
if (isSelected) {
card.classList.add('selected');
// Обновить иконку
var icon = card.querySelector('.bi-circle');
if (icon) {
icon.classList.remove('bi-circle', 'text-muted');
icon.classList.add('bi-check-circle-fill', 'text-success');
}
// Добавить иконку в grid view если её нет
if (this.state.currentView === 'grid') {
var posRel = card.querySelector('.position-relative');
if (posRel && !posRel.querySelector('.check-icon')) {
var checkEl = document.createElement('i');
checkEl.className = 'bi bi-check-circle-fill text-success check-icon';
posRel.appendChild(checkEl);
}
}
} else {
card.classList.remove('selected');
var iconCheck = card.querySelector('.bi-check-circle-fill');
if (iconCheck) {
// В list view меняем иконку
if (this.state.currentView === 'list') {
iconCheck.classList.remove('bi-check-circle-fill', 'text-success');
iconCheck.classList.add('bi-circle', 'text-muted');
} else {
// В grid view удаляем иконку
if (iconCheck.classList.contains('check-icon')) {
iconCheck.remove();
}
}
}
}
};
/**
* Обновление UI выбора (счетчик, кнопки)
*/
ProductSearchPicker.prototype._updateSelectionUI = function() {
var count = Object.keys(this.state.selected).length;
// Счетчик
if (this.elements.selectedCount) {
this.elements.selectedCount.textContent = count;
}
// Кнопка "Добавить"
if (this.elements.addSelectedBtn) {
this.elements.addSelectedBtn.disabled = count === 0;
}
};
/**
* Выбрать все видимые товары
*/
ProductSearchPicker.prototype._selectAll = function() {
var self = this;
var currentCount = Object.keys(this.state.selected).length;
var maxToSelect = this.options.maxSelection
? this.options.maxSelection - currentCount
: Infinity;
var added = 0;
this.state.products.forEach(function(product) {
if (added >= maxToSelect) return;
var productId = String(product.id).replace('product_', '');
if (!self.state.selected.hasOwnProperty(productId)) {
self.state.selected[productId] = product;
self._updateProductUI(productId);
added++;
}
});
this._updateSelectionUI();
if (this.options.onSelectionChange) {
this.options.onSelectionChange(this.getSelected(), this);
}
};
/**
* Обновление кнопок переключателя вида
*/
ProductSearchPicker.prototype._updateViewButtons = function() {
var self = this;
this.elements.viewButtons.forEach(function(btn) {
if (btn.dataset.view === self.state.currentView) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
};
/**
* Переключение вида (grid/list)
*/
ProductSearchPicker.prototype._setView = function(view) {
this.state.currentView = view;
// Обновить data-атрибут
if (this.elements.grid) {
this.elements.grid.dataset.view = view;
}
// Обновить кнопки
this._updateViewButtons();
// Перерисовать
this._renderProducts(this.state.products, false);
};
// ========== ПУБЛИЧНОЕ API ==========
/**
* Получить массив выбранных товаров
*/
ProductSearchPicker.prototype.getSelected = function() {
var result = [];
for (var key in this.state.selected) {
if (this.state.selected.hasOwnProperty(key)) {
result.push(this.state.selected[key]);
}
}
return result;
};
/**
* Получить массив ID выбранных товаров
*/
ProductSearchPicker.prototype.getSelectedIds = function() {
return Object.keys(this.state.selected);
};
/**
* Сбросить выбор
*/
ProductSearchPicker.prototype.clearSelection = function() {
var self = this;
var ids = Object.keys(this.state.selected);
this.state.selected = {};
ids.forEach(function(productId) {
self._updateProductUI(productId);
});
this._updateSelectionUI();
if (this.options.onSelectionChange) {
this.options.onSelectionChange([], this);
}
};
/**
* Установить выбор программно
*/
ProductSearchPicker.prototype.setSelection = function(productIds) {
var self = this;
this.clearSelection();
productIds.forEach(function(id) {
var productId = String(id).replace('product_', '');
// Ищем товар в загруженном списке
for (var i = 0; i < self.state.products.length; i++) {
var p = self.state.products[i];
if (String(p.id).replace('product_', '') === productId) {
self.state.selected[productId] = p;
self._updateProductUI(productId);
break;
}
}
});
this._updateSelectionUI();
};
/**
* Обновить список товаров
*/
ProductSearchPicker.prototype.refresh = function() {
this.state.currentPage = 1;
this._loadProducts();
};
/**
* Уничтожить экземпляр
*/
ProductSearchPicker.prototype.destroy = function() {
// Очистить состояние
this.state.products = [];
this.state.selected = {};
// Удалить из хранилища
if (this.container.id && instances[this.container.id]) {
delete instances[this.container.id];
}
};
// ========== СТАТИЧЕСКИЕ МЕТОДЫ ==========
/**
* Инициализация компонента
* @param {string|Element} container - селектор или DOM элемент
* @param {Object} options - настройки
* @returns {ProductSearchPicker}
*/
ProductSearchPicker.init = function(container, options) {
var element = typeof container === 'string'
? document.querySelector(container)
: container;
if (!element) {
console.error('ProductSearchPicker.init: container not found');
return null;
}
// Проверяем, не инициализирован ли уже
if (element.id && instances[element.id]) {
return instances[element.id];
}
var instance = new ProductSearchPicker(element, options);
if (element.id) {
instances[element.id] = instance;
}
return instance;
};
/**
* Получить экземпляр по контейнеру
*/
ProductSearchPicker.getInstance = function(container) {
var element = typeof container === 'string'
? document.querySelector(container)
: container;
return element && element.id ? instances[element.id] : null;
};
// Экспорт в глобальную область
window.ProductSearchPicker = ProductSearchPicker;
})(window);