/* ========================================================================= * catalog.js — каталог: филтри, сортиране, грид (клиентски рендер, фаза 1) * Поведението повтаря оригиналния макет: URL параметри tab, category, q, * type, brand, culture, sub, pkg, avail, wh, sort; страниране по 12. * Фаза 2: същата логика става server-side в Spring MVC контролер * (правило от salex: sort/filter параметрите идват от URL). * ========================================================================= */ (function () { 'use strict'; var esc = window.ACM.esc, fmt = window.ACM.fmtPrice, DEMO = window.ACM.demo; var PRODUCTS = window.ACM_PRODUCTS || []; var PAGE_SIZE = 12; /* ---------------------- Икони (Lucide, inline) ------------------------- */ function icon(paths, size, sw) { return ''; } var IC = { shield: '', sprout: '', droplets: '', grid: '', wheat: '', sun: '', flower: '', grape: '', carrot: '', clover: '', minus: '', plus: '', x: '', chevron: '', package: '' }; var CULTURE_ICONS = { 'Пшеница': IC.wheat, 'Царевица': IC.sprout, 'Слънчоглед': IC.sun, 'Рапица': IC.flower, 'Ечемик': IC.wheat, 'Лозя': IC.grape, 'Зеленчуци': IC.carrot, 'Люцерна': IC.clover }; var QUICKNAV = [ { category: 'Растителна защита', label: 'Растителна защита', ic: IC.shield, activeCls: 'border-green-deep bg-green-deep text-white', idleChip: 'bg-green-tint text-green-deep', idleHover: 'hover:border-green-deep' }, { category: 'Семена', label: 'Семена', ic: IC.sprout, activeCls: 'border-orange-deep bg-orange-deep text-white', idleChip: 'bg-[#fdf1e8] text-orange-deep', idleHover: 'hover:border-orange-deep' }, { category: 'Торове', label: 'Торове', ic: IC.droplets, activeCls: 'border-info bg-info text-white', idleChip: 'bg-[#eef5ff] text-info', idleHover: 'hover:border-info' }, { category: '', label: 'Всички продукти', ic: IC.grid, activeCls: 'border-ink bg-ink text-white', idleChip: 'bg-ink/[0.055] text-ink', idleHover: 'hover:border-ink' } ]; /* ---------------------- Състояние от URL ------------------------------- */ function listParam(v) { return v ? v.split(',').filter(Boolean) : []; } function readState() { var p = new URLSearchParams(window.location.search); var sort = p.get('sort'); return { tab: p.get('tab') === 'culture' ? 'culture' : 'category', category: p.get('category') || '', q: p.get('q') || '', types: listParam(p.get('type')), brands: listParam(p.get('brand')), cultures: listParam(p.get('culture')), substances: listParam(p.get('sub')), packages: listParam(p.get('pkg')), onlyAvailable: p.get('avail') === '1', warehouse: p.get('wh') || '', sort: (sort === 'price-asc' || sort === 'price-desc' || sort === 'stock') ? sort : 'name' }; } function writeState(s) { var p = new URLSearchParams(); if (s.tab === 'culture') p.set('tab', 'culture'); if (s.category) p.set('category', s.category); if (s.q) p.set('q', s.q); if (s.types.length) p.set('type', s.types.join(',')); if (s.brands.length) p.set('brand', s.brands.join(',')); if (s.cultures.length) p.set('culture', s.cultures.join(',')); if (s.substances.length) p.set('sub', s.substances.join(',')); if (s.packages.length) p.set('pkg', s.packages.join(',')); if (s.onlyAvailable) p.set('avail', '1'); if (s.warehouse) p.set('wh', s.warehouse); if (s.sort !== 'name') p.set('sort', s.sort); var qs = p.toString(); history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : '')); } var state = readState(); var shown = PAGE_SIZE; /* ---------------------- Филтрираща логика (като оригинала) -------------- */ var WH_BY_NAME = {}; DEMO.warehouses.forEach(function (w) { WH_BY_NAME[w.name] = w.key; }); function packageBucket(p) { var u = p.package.unitLabel, bc = p.package.boxContent; if (u === 'кг') return 'кг'; if (u === 'пакет' || u === 'бр.') return 'брой'; var n = parseFloat(String(bc).replace(/[^0-9.,]/g, '').replace(',', '.')); if (!isFinite(n)) return ''; return n <= 1 ? 'до 1 л' : n <= 5 ? '1–5 л' : n <= 20 ? '5–20 л' : '20+ л'; } function applyFilters(list, s) { var wh = s.warehouse ? WH_BY_NAME[s.warehouse] : null; var q = s.q.trim().toLowerCase(); return list.filter(function (e) { if (s.category && e.category !== s.category) return false; if (q) { var inName = e.name.toLowerCase().indexOf(q) !== -1; var inSub = e.activeSubstances.some(function (a) { return a.name.toLowerCase().indexOf(q) !== -1; }); if (!inName && !inSub) return false; } if (s.types.length && s.types.indexOf(e.productType) === -1) return false; if (s.brands.length && s.brands.indexOf(e.brand) === -1) return false; if (s.cultures.length && !s.cultures.some(function (c) { return e.cultureTags.indexOf(c) !== -1; })) return false; if (s.substances.length && !e.activeSubstances.some(function (a) { return s.substances.indexOf(a.name) !== -1; })) return false; if (s.packages.length && s.packages.indexOf(packageBucket(e)) === -1) return false; if (s.onlyAvailable) { if (wh) { var st = e.stock[wh]; if (st !== 'green' && st !== 'orange') return false; } else if (!Object.keys(e.stock).some(function (k) { return e.stock[k] === 'green' || e.stock[k] === 'orange'; })) { return false; } } return true; }); } var STOCK_RANK = { green: 0, orange: 1, grey: 2, red: 3 }; function stockRank(p) { return Math.min.apply(null, Object.keys(p.stock).map(function (k) { return STOCK_RANK[p.stock[k]]; })); } function sortProducts(list, sort) { var a = list.slice(); if (sort === 'price-asc') a.sort(function (x, y) { return x.price.perBox - y.price.perBox; }); else if (sort === 'price-desc') a.sort(function (x, y) { return y.price.perBox - x.price.perBox; }); else if (sort === 'stock') a.sort(function (x, y) { return stockRank(x) - stockRank(y) || x.name.localeCompare(y.name, 'bg'); }); else a.sort(function (x, y) { return x.name.localeCompare(y.name, 'bg'); }); return a; } /* Броячи по измерение (без собственото, като оригинала) */ function computeCounts(s) { var types = {}, brands = {}, cultures = {}; applyFilters(PRODUCTS, mergeState(s, { types: [] })).forEach(function (p) { types[p.productType] = (types[p.productType] || 0) + 1; }); applyFilters(PRODUCTS, mergeState(s, { brands: [] })).forEach(function (p) { if (p.brand) brands[p.brand] = (brands[p.brand] || 0) + 1; }); applyFilters(PRODUCTS, mergeState(s, { cultures: [] })).forEach(function (p) { p.cultureTags.forEach(function (c) { cultures[c] = (cultures[c] || 0) + 1; }); }); return { types: types, brands: brands, cultures: cultures }; } function mergeState(s, patch) { var out = {}; Object.keys(s).forEach(function (k) { out[k] = s[k]; }); Object.keys(patch).forEach(function (k) { out[k] = patch[k]; }); return out; } /* ---------------------- Продуктова карта (както в оригинала) ------------ */ function stockButtons(p) { return DEMO.warehouses.map(function (w) { var st = DEMO.stockStatus[p.stock[w.key]]; return '' + ''; }).join(''); } function productCard(p) { var brandBadge = p.brand ? '' + '' + esc(p.brand) + '' : ''; var img = p.image ? '' + esc(p.name) + '' : '' + icon(IC.package, 48, 1.25) + ''; var chips = 'кашон ' + esc(p.package.boxContent) + ''; if (p.package.palletSize) { chips += 'пале ' + esc(p.package.palletSize) + ''; } var isPack = p.package.unitLabel === 'пакет'; var qtyUnit = isPack ? 'бр.' : 'каш.'; return '
' + '
' + '' + '
' + brandBadge + img + '
' + '
' + '

' + esc(p.subcategory) + '

' + '

' + esc(p.name) + '

' + '

Арт. № ' + esc(p.artNumber) + '

' + '
' + chips + '
' + '
' + '

Цена на ' + (isPack ? 'пакет' : 'кашон') + '

' + '

' + fmt(p.price.perBox) + '

' + '

Цена на ' + esc(p.package.unitLabel) + ': ' + fmt(p.price.perUnit) + '

' + '
' + '
' + stockButtons(p) + '
' + '
' + '
' + '
' + '' + '
' + '' + '' + qtyUnit + '' + '
' + '' + '
' + '' + '
'; } /* ---------------------- Рендер: бърза навигация ------------------------- */ function renderQuickNav() { var counts = {}; PRODUCTS.forEach(function (p) { counts[p.category] = (counts[p.category] || 0) + 1; }); var el = document.getElementById('catalog-quicknav'); el.innerHTML = QUICKNAV.map(function (c) { var active = state.category === c.category && state.tab === 'category'; var n = c.category === '' ? PRODUCTS.length : (counts[c.category] || 0); return ''; }).join(''); Array.prototype.forEach.call(el.querySelectorAll('button[data-cat]'), function (b) { b.addEventListener('click', function () { var c = b.getAttribute('data-cat'); update({ tab: 'category', category: state.category === c ? '' : c }); }); }); } /* ---------------------- Рендер: табове и култури ------------------------ */ function renderTabs() { var tabCat = document.getElementById('catalog-tab-category'); var tabCul = document.getElementById('catalog-tab-culture'); var underline = ''; [[tabCat, 'category', 'По категория'], [tabCul, 'culture', 'По култура']].forEach(function (t) { var el = t[0], key = t[1], label = t[2], sel = state.tab === key; el.setAttribute('aria-selected', String(sel)); el.className = 'relative min-h-[44px] px-4 text-[15px] font-medium transition-colors ' + (sel ? 'text-ink' : 'text-muted hover:text-ink'); el.innerHTML = esc(label) + (sel ? underline : ''); }); document.getElementById('catalog-culture-picker').hidden = state.tab !== 'culture'; document.getElementById('catalog-layout').className = state.tab === 'category' ? 'lg:grid lg:grid-cols-[280px_1fr] lg:gap-8' : ''; document.getElementById('catalog-sidebar').style.display = state.tab === 'category' ? '' : 'none'; document.getElementById('catalog-filters-open').style.display = state.tab === 'category' ? '' : 'none'; if (state.tab === 'culture') renderCultureChips(); } function renderCultureChips() { var el = document.getElementById('catalog-culture-chips'); el.innerHTML = DEMO.cultures.map(function (c) { var sel = state.cultures.indexOf(c) !== -1; return ''; }).join(''); Array.prototype.forEach.call(el.querySelectorAll('button[data-culture]'), function (b) { b.addEventListener('click', function () { var c = b.getAttribute('data-culture'); var list = state.cultures.slice(); var i = list.indexOf(c); if (i === -1) list.push(c); else list.splice(i, 1); update({ cultures: list }); }); }); } /* ---------------------- Рендер: панел с филтри -------------------------- */ function checkRow(group, value, label, count, checked) { return ''; } function filterSection(title, inner, open) { return '
' + '' + esc(title) + '' + icon(IC.chevron, 16) + '' + '
' + inner + '
'; } function uniqueSubstances() { var set = {}; PRODUCTS.forEach(function (p) { p.activeSubstances.forEach(function (s) { set[s.name] = true; }); }); return Object.keys(set).sort(function (a, b) { return a.localeCompare(b, 'bg'); }); } function renderFilters() { var counts = computeCounts(state); var types = {}; PRODUCTS.forEach(function (p) { types[p.productType] = true; }); var html = ''; html += filterSection('Тип продукт', Object.keys(types).sort(function (a, b) { return a.localeCompare(b, 'bg'); }).map(function (t) { return checkRow('types', t, t, counts.types[t] || 0, state.types.indexOf(t) !== -1); }).join(''), true); var brands = {}; PRODUCTS.forEach(function (p) { if (p.brand) brands[p.brand] = true; }); html += filterSection('Марка', Object.keys(brands).sort().map(function (b) { return checkRow('brands', b, b, counts.brands[b] || 0, state.brands.indexOf(b) !== -1); }).join(''), state.brands.length > 0); html += filterSection('Култура', DEMO.cultures.map(function (c) { return checkRow('cultures', c, c, counts.cultures[c] || 0, state.cultures.indexOf(c) !== -1); }).join(''), state.cultures.length > 0); html += filterSection('Активно вещество', uniqueSubstances().map(function (s) { return checkRow('substances', s, s, null, state.substances.indexOf(s) !== -1); }).join(''), state.substances.length > 0); html += filterSection('Опаковка', DEMO.packagingBuckets.map(function (b) { return checkRow('packages', b, b, null, state.packages.indexOf(b) !== -1); }).join(''), state.packages.length > 0); var whOptions = '' + DEMO.warehouses.map(function (w) { return ''; }).join(''); html += filterSection('Наличност', checkRow('avail', '1', 'Само налични', null, state.onlyAvailable) + '', state.onlyAvailable); ['catalog-filters', 'catalog-filters-mobile'].forEach(function (id) { var el = document.getElementById(id); el.innerHTML = html; Array.prototype.forEach.call(el.querySelectorAll('input[data-fgroup]'), function (inp) { inp.addEventListener('change', function () { var g = inp.getAttribute('data-fgroup'); if (g === 'avail') { update({ onlyAvailable: inp.checked }); return; } var list = state[g].slice(); var i = list.indexOf(inp.value); if (inp.checked && i === -1) list.push(inp.value); if (!inp.checked && i !== -1) list.splice(i, 1); var patch = {}; patch[g] = list; update(patch); }); }); var sel = el.querySelector('select[data-fgroup="warehouse"]'); if (sel) sel.addEventListener('change', function () { update({ warehouse: sel.value }); }); }); } /* ---------------------- Рендер: активни филтри -------------------------- */ function activeFilterChips(s) { var chips = []; s.types.forEach(function (v) { chips.push({ key: 'type:' + v, label: v }); }); s.brands.forEach(function (v) { chips.push({ key: 'brand:' + v, label: v }); }); s.cultures.forEach(function (v) { chips.push({ key: 'culture:' + v, label: v }); }); s.substances.forEach(function (v) { chips.push({ key: 'sub:' + v, label: v }); }); s.packages.forEach(function (v) { chips.push({ key: 'pkg:' + v, label: v }); }); if (s.onlyAvailable) chips.push({ key: 'avail', label: s.warehouse ? 'Налични: ' + s.warehouse : 'Само налични' }); if (s.q) chips.push({ key: 'q', label: 'Търсене: „' + s.q + '"' }); return chips; } function removeChip(key) { if (key === 'avail') { update({ onlyAvailable: false }); return; } if (key === 'q') { update({ q: '' }); return; } var i = key.indexOf(':'); var g = { type: 'types', brand: 'brands', culture: 'cultures', sub: 'substances', pkg: 'packages' }[key.slice(0, i)]; var v = key.slice(i + 1); if (!g) return; var patch = {}; patch[g] = state[g].filter(function (x) { return x !== v; }); update(patch); } function renderChipsRow(resultCount) { var chips = activeFilterChips(state); var el = document.getElementById('catalog-chips'); var countTxt = '' + resultCount + ' ' + (resultCount === 1 ? 'продукт' : 'продукта') + ''; if (!chips.length) { el.innerHTML = countTxt; return; } el.innerHTML = '
' + countTxt + chips.map(function (c) { return ''; }).join('') + '
'; Array.prototype.forEach.call(el.querySelectorAll('button[data-chip]'), function (b) { b.addEventListener('click', function () { removeChip(b.getAttribute('data-chip')); }); }); var clr = el.querySelector('button[data-clear]'); if (clr) clr.addEventListener('click', clearFilters); } function clearFilters() { update({ types: [], brands: [], cultures: [], substances: [], packages: [], onlyAvailable: false, warehouse: '', q: '' }); } /* ---------------------- Рендер: грид ------------------------------------ */ function renderGrid() { var results = sortProducts(applyFilters(PRODUCTS, state), state.sort); var visible = results.slice(0, shown); var grid = document.getElementById('catalog-grid'); grid.innerHTML = visible.map(productCard).join(''); document.getElementById('catalog-empty').hidden = results.length > 0; document.getElementById('catalog-more-wrap').hidden = results.length <= shown; renderChipsRow(results.length); document.getElementById('catalog-filters-open-label').textContent = 'Филтри (' + activeFilterChips(state).length + ')'; document.getElementById('catalog-sheet-apply').textContent = 'Покажи ' + results.length + ' ' + (results.length === 1 ? 'продукт' : 'продукта'); document.getElementById('catalog-breadcrumb').textContent = state.category || 'Каталог'; /* Свързване на купуващите контроли (стъпер + Добавяне) */ Array.prototype.forEach.call(grid.querySelectorAll('div.group'), function (card) { var a = card.querySelector('a[href^="/product/"]'); if (!a) return; var id = (a.getAttribute('href').match(/^\/product\/([^\/]+)\//) || [])[1]; var input = card.querySelector('input[aria-label="Количество"]'); var btns = card.querySelectorAll('.inline-flex.items-stretch button'); var addBtn = card.querySelector('button[data-add]'); function val() { var v = parseInt(input.value, 10); return isNaN(v) ? 1 : Math.min(9999, Math.max(1, v)); } function sync() { input.value = String(val()); btns[0].disabled = val() <= 1; btns[1].disabled = val() >= 9999; } btns[0].addEventListener('click', function () { input.value = String(val() - 1); sync(); }); btns[1].addEventListener('click', function () { input.value = String(val() + 1); sync(); }); input.addEventListener('change', sync); addBtn.addEventListener('click', function () { var p = window.ACM.findProduct(id); window.ACM.cart.add(id, val()); window.ACM.showToast(p.name + ' е добавен в количката', { actionLabel: 'Виж количката', onAction: function () { window.location.href = '/cart/'; } }); }); }); } /* ---------------------- Обновяване -------------------------------------- */ function update(patch) { state = mergeState(state, patch); shown = PAGE_SIZE; writeState(state); renderAll(); } function renderAll() { renderQuickNav(); renderTabs(); renderFilters(); renderGrid(); } /* ---------------------- Инициализация ----------------------------------- */ document.addEventListener('DOMContentLoaded', function () { document.getElementById('catalog-tab-category').addEventListener('click', function () { update({ tab: 'category' }); }); document.getElementById('catalog-tab-culture').addEventListener('click', function () { update({ tab: 'culture' }); }); document.getElementById('catalog-sort').value = state.sort; document.getElementById('catalog-sort').addEventListener('change', function (e) { update({ sort: e.target.value }); }); document.getElementById('catalog-more').addEventListener('click', function () { shown += PAGE_SIZE; renderGrid(); }); document.getElementById('catalog-empty-clear').addEventListener('click', clearFilters); var sheet = document.getElementById('catalog-sheet'); function setSheet(open) { sheet.hidden = !open; document.body.style.overflow = open ? 'hidden' : ''; } document.getElementById('catalog-filters-open').addEventListener('click', function () { setSheet(true); }); document.getElementById('catalog-sheet-close').addEventListener('click', function () { setSheet(false); }); document.getElementById('catalog-sheet-scrim').addEventListener('click', function () { setSheet(false); }); document.getElementById('catalog-sheet-apply').addEventListener('click', function () { setSheet(false); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !sheet.hidden) setSheet(false); }); renderAll(); }); })();