const host = 'https://local.hvatilo.ru:8055'; // Returns a function, that, when invoked, will only be triggered at most once // during a given window of time. Normally, the throttled function will run // as much as it can, without ever going more than once per `wait` duration; // but if you'd like to disable the execution on the leading edge, pass // `{leading: false}`. To disable execution on the trailing edge, ditto. function throttle(func, wait, options) { var context, args, result; var timeout = null; var previous = 0; if (!options) options = {}; var later = function() { previous = options.leading === false ? 0 : Date.now(); timeout = null; result = func.apply(context, args); if (!timeout) context = args = null; }; return function() { var now = Date.now(); if (!previous && options.leading === false) previous = now; var remaining = wait - (now - previous); context = this; args = arguments; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; result = func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } return result; }; } function refreshToken() { return fetch(host + '/api/guest/refresh_cookie', {method: 'POST', credentials: 'include'}).then(response => { return response.json(); }) } function initToken() { return fetch(host + '/api/guest/init_cookie?utm_source=hvatilo_promo', {method: 'POST', credentials: 'include'}).then(response => { return response.json(); }); } const requestWithRefresh = async function(url, options) { return fetch(url, {...options, credentials: 'include'}) .then(async response => response.json()) .then(async data => { if (data.error_message === 'need_refresh') { return refreshToken() .then((r) => { console.log('url', url); return fetch(url, {...options, credentials: 'include'}).then(response => response.json()); }); } return data; }) } const replaceProduct = (async function(product_name) { return requestWithRefresh(host + '/api/promo/replace_item', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({product_name}) }) .then(async () => { updateCart(); }) }) const deleteProduct = (async function(product_name) { return requestWithRefresh(host + '/api/promo/delete_item', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({product_name}) }) }) const updateCart = throttle(async function() { return requestWithRefresh(host + '/api/promo/get_updates', {headers: { 'Content-Type': 'application/json' }}).then(d => { if (d.status !== 'complete') { initialData_g.excess = d.payload.excess; initialData_g.precision = d.payload.precision; if (d.status === 'clear') { allProducts = []; visibleProducts = []; renderProducts(); allProducts = d.payload.chunk; visibleProducts = d.payload.chunk; } if (d.status === 'data') { d.payload.chunk.forEach(addedProduct => { let idx = allProducts.findIndex(p => p.name === addedProduct.name) if (idx === -1) { allProducts.push({...addedProduct, image: addedProduct.image?.replace('{SIZE}', '65,fit')}); if (visibleProducts.length === allProducts.length - 1) { visibleProducts = allProducts; } } else { allProducts[idx].n += addedProduct.n; if (idx < visibleProducts.length) { visibleProducts[idx] = allProducts[idx]; } } }) } console.log(allProducts.length, visibleProducts.length); if (d.status !== 'waiting') { renderProducts(); } updateCart(); } }) .catch(() => { updateCart(); }) }, 3000); const setOption = (key, value) => { return requestWithRefresh(host + '/api/promo/set_option', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({[key]: value}) }) .then(async d => { wholeRender(await loadInitData()); updateCart(); }) } const loadInitData = (async function() { return fetch(host + '/api/promo/get_initial_data', {credentials: 'include'}) .then(async response => { const data = await response.json(); if (response.status === 401 && data.error_message !== 'need_refresh') { return await initToken() .then((r) => { return fetch(host + '/api/promo/get_initial_data', {credentials: 'include'}) .then(r => r.json()); }); } if (response.status === 403 || data.error_message === 'need_refresh') { return await refreshToken() .then((r) => { return fetch(host + '/api/promo/get_initial_data', {credentials: 'include'}) .then(r => r.json()); }); } return data; }) .then((data) => { updateCart(); return data; }) .catch(error => { console.log(error); return Promise.reject(); }) }); function debounce(func, delay) { let timeoutId; return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { func.apply(this, args); }, delay); }; } let allProducts; let visibleProducts; function renderProduct(item, index) { const li = document.createElement('li'); li.className = 'cart-item'; const amount = item.unit === 'g' ? item.n + ' г' : item.n + ' шт'; li.innerHTML = cartPolicy.createHTML(`
${item.name}
${amount} × ${Math.round(item.price * 100) / 100} ₽
`); return li; } let cartList; let scrollWrapper; let showMoreBtn; let remainingSpan; let initialData_g; const cartPolicy = trustedTypes.createPolicy('cartPolicy', { createHTML: (string) => string }); function renderProducts() { cartList.innerHTML = cartPolicy.createHTML(''); visibleProducts.forEach((item, index) => { cartList.appendChild(renderProduct(item, index)); }); const remaining = allProducts.length - visibleProducts.length; if(remaining > 0) { showMoreBtn.classList.remove('hidden'); remainingSpan.textContent = remaining; } else { showMoreBtn.classList.add('hidden'); } updateSummaryCount(); calculateListHeight(); } function updateSummaryCount() { const totalPrice = new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB" }).format( Math.round(allProducts.reduce((sum, item) => sum + item.total_price, 0) * 100) / 100, ); const x = Math.round(initialData_g.days * initialData_g.excess + initialData_g.days); document.getElementById('summary-details').innerHTML = cartPolicy.createHTML(`${allProducts.length} ${declension(allProducts.length, ['товар', 'товара', 'товаров'])} • ${totalPrice} ~ с запасами на ${x} ${declension(x, ['день', 'дня', 'дней'])}, точность ${Math.round(initialData_g.precision * 100)/100}%`); document.getElementById('total-price').innerHTML = cartPolicy.createHTML(`${totalPrice}`); if(allProducts.length === 0) { document.getElementById('summary-details').innerHTML = cartPolicy.createHTML(`0 товаров • ${totalPrice} ₽`); } } function calculateListHeight() { const leftCol = document.querySelector('.col-left'); if (!leftCol) return; const title = leftCol.querySelector('h1'); const subtitle = leftCol.querySelector('.subtitle'); const summary = leftCol.querySelector('.cart-summary'); const footer = leftCol.querySelector('.cart-footer'); const showMore = leftCol.querySelector('.show-more'); let fixedHeight = 0; if(title) fixedHeight += title.offsetHeight + 6; if(subtitle) fixedHeight += subtitle.offsetHeight + 28; if(summary) fixedHeight += summary.offsetHeight + 24; if(showMore && !showMore.classList.contains('hidden')) fixedHeight += showMore.offsetHeight; if(footer) fixedHeight += footer.offsetHeight + 20; const paddingY = 60; const rect = leftCol.getBoundingClientRect(); const availHeight = window.innerHeight - rect.top - paddingY - 20; const listHeight = availHeight - fixedHeight; if(listHeight > 100) { scrollWrapper.style.maxHeight = `${listHeight}px`; scrollWrapper.style.overflowY = 'auto'; } } function declension(number, titles) { const cases = [2, 0, 1, 1, 1, 2]; const idx = (number % 100 > 4 && number % 100 < 20) ? 2 : cases[Math.min(number % 10, 5)]; return titles[idx]; } function wholeRender(initialData) { initialData_g = initialData; document.querySelector('.summary-days').textContent = `${initialData_g.days} ${declension(initialData_g.days, ['день', 'дня', 'дней'])}`; allProducts = (Array.isArray(initialData_g.cart) ? initialData_g.cart : [] ?? []).flat().map(item => { return {...item, image: item.image?.replace('{SIZE}', '65,fit')} }); visibleProducts = allProducts.slice(0, visibleProducts ? visibleProducts.length : 5); document.querySelectorAll('.days-options .day-btn').forEach(btn => { if (btn.innerText.trim() === `${initialData_g.days}`) { btn.classList.add('active'); } }); // Массив людей теперь содержит поля запрещенных и любимых продуктов console.log(initialData_g); let people = initialData_g.users; // --- 1. Рендеринг списка продуктов (Левая часть) --- cartList = document.getElementById('cart-list'); scrollWrapper = document.getElementById('scroll-wrapper'); showMoreBtn = document.getElementById('show-more-btn'); remainingSpan = document.getElementById('remaining-count'); renderProducts(); // --- 2. Меню (Заменить и Удалить) --- document.addEventListener('click', function(e) { const menuBtn = e.target.closest('.menu-btn'); const menuDropdown = e.target.closest('.menu-dropdown'); const isReplace = e.target.closest('.menu-replace'); const isRemove = e.target.closest('.menu-remove'); const isClearAll = e.target.closest('.reorder-btn'); if(menuBtn) { const dropdown = menuBtn.nextElementSibling; const isOpen = dropdown.classList.contains('active'); document.querySelectorAll('.menu-dropdown.active').forEach(el => el.classList.remove('active')); if(!isOpen) { dropdown.classList.add('active'); } e.stopPropagation(); } else if(!menuDropdown) { document.querySelectorAll('.menu-dropdown.active').forEach(el => el.classList.remove('active')); } if(isReplace) { const wrapper = isReplace.closest('.menu-btn-wrapper'); const li = wrapper.closest('.cart-item'); li.classList.add('replacing'); const index = Array.from(cartList.children).indexOf(li); replaceProduct(allProducts[index].name).then(() => { li.remove(); document.querySelectorAll('.menu-dropdown.active').forEach(el => el.classList.remove('active')); visibleProducts.splice(index, 1); allProducts.splice(index, 1); if(visibleProducts.length < allProducts.length) { visibleProducts.push(allProducts[visibleProducts.length]); } renderProducts(); calculateListHeight(); }); } if(isRemove) { const wrapper = isRemove.closest('.menu-btn-wrapper'); const li = wrapper.closest('.cart-item'); li.classList.add('replacing'); const index = Array.from(cartList.children).indexOf(li); deleteProduct(allProducts[index].name).then(() => { li.remove(); document.querySelectorAll('.menu-dropdown.active').forEach(el => el.classList.remove('active')); visibleProducts.splice(index, 1); allProducts.splice(index, 1); if(visibleProducts.length < allProducts.length) { visibleProducts.push(allProducts[visibleProducts.length]); } renderProducts(); calculateListHeight(); }) } if (isClearAll) { requestWithRefresh(host + '/api/guest/clear_cookie', {method: 'POST'}) .then(r => {document.location.reload()}); } }); showMoreBtn.addEventListener('click', () => { while(visibleProducts.length < allProducts.length) { visibleProducts.push(allProducts[visibleProducts.length]); } renderProducts(); scrollWrapper.style.maxHeight = '500px'; calculateListHeight(); }); // --- 3. Фиксированная высота --- window.addEventListener('resize', calculateListHeight); calculateListHeight(); // --- 4. Аккордеон (активна только одна вкладка) --- document.querySelectorAll('.accordion-header').forEach(header => { header.addEventListener('click', function() { const item = this.closest('.accordion-item'); const isOpen = item.classList.contains('open'); document.querySelectorAll('.accordion-item').forEach(el => el.classList.remove('open')); if(!isOpen) { item.classList.add('open'); } }); }); // --- 5. Дни --- document.querySelectorAll('.day-btn').forEach(btn => { btn.addEventListener('click', function() { const parent = this.closest('.days-options'); parent.querySelectorAll('.day-btn').forEach(b => b.classList.remove('active')); this.classList.add('active'); setOption('days', this.dataset.days); document.querySelector('.summary-days').textContent = `${this.dataset.days} ${declension(this.dataset.days, ['день', 'дня', 'дней'])}`; }); }); // --- 6. Семья с возможностью редактирования --- const familyList = document.getElementById('family-list'); const familyDesc = document.getElementById('family-desc'); let editingIndex = null; function renderPeople() { familyList.innerHTML = cartPolicy.createHTML(''); people.forEach((person, index) => { const avatar = '🥦'; const div = document.createElement('div'); div.className = 'family-member'; div.dataset.index = index; div.innerHTML = cartPolicy.createHTML(`
${avatar}
${person.name}
${person.age} лет
×
`); familyList.appendChild(div); }); const count = people.length; familyDesc.textContent = `${count} ${declension(count, ['человек', 'человека', 'человек'])}`; if(count === 0) familyDesc.textContent = 'Не указаны'; document.querySelectorAll('.delete-member').forEach(btn => { btn.addEventListener('click', function(e) { e.stopPropagation(); const idx = parseInt(this.dataset.index); people.splice(idx, 1); renderPeople(); setOption('users', people); }); }); } renderPeople(); familyList.addEventListener('click', function(e) { const member = e.target.closest('.family-member'); if (!member) return; if (e.target.closest('.delete-member')) return; const idx = parseInt(member.dataset.index); openEditModal(idx); }); // --- 7. Попап (Добавление и Редактирование) + Логика Тегов --- const modal = document.getElementById('personModal'); const addBtn = document.getElementById('addMemberBtn'); const closeBtn = document.getElementById('closeModalBtn'); const saveBtn = document.getElementById('savePersonBtn'); const modalTitle = document.getElementById('modalTitle'); const genderBtns = document.querySelectorAll('.gender-btn'); const availableTags = initialData_g.available_groups; function openEditModal(index = null) { editingIndex = index; // Очистка тегов внутри попапа document.querySelectorAll('#personModal .tags-wrap').forEach(el => el.innerHTML = cartPolicy.createHTML('')); if (editingIndex !== null) { modalTitle.textContent = 'Редактировать человека'; const p = people[editingIndex]; document.getElementById('inputName').value = p.name; document.getElementById('inputAge').value = p.age; document.getElementById('inputWeight').value = p.weight; document.getElementById('inputHeight').value = p.height; genderBtns.forEach(b => { b.classList.toggle('active', b.dataset.gender === p.gender); }); // Рендерим сохраненные теги if(p.avoid) p.avoid.forEach(tag => addTagToModal(tag, 'avoid', false)); if(p.favorite) p.favorite.forEach(tag => addTagToModal(tag, 'favorite', false)); } else { modalTitle.textContent = 'Добавить человека'; document.getElementById('inputName').value = ''; document.getElementById('inputAge').value = ''; document.getElementById('inputWeight').value = ''; document.getElementById('inputHeight').value = ''; genderBtns.forEach(b => b.classList.remove('active')); document.querySelector('.gender-btn[data-gender="m"]').classList.add('active'); } modal.style.display = 'flex'; calculateListHeight(); } addBtn.addEventListener('click', () => openEditModal(null)); function closeModal() { modal.style.display = 'none'; } closeBtn.addEventListener('click', closeModal); modal.addEventListener('click', function(e) { if (e.target === this) closeModal(); }); genderBtns.forEach(btn => { btn.addEventListener('click', function() { genderBtns.forEach(b => b.classList.remove('active')); this.classList.add('active'); }); }); // Логика добавления тега в попап function addTagToModal(text, type, saveToPeople = true) { const tagsWrap = document.querySelector(`#personModal .tags-wrap[data-target="${type}"]`); // Проверка дубликата в своей группе const existingInGroup = tagsWrap.querySelectorAll('.tag'); for(let t of existingInGroup) { if(t.textContent.trim().replace('×','').trim() === text) return; } // Взаимоисключение: если тег есть в противоположной группе - удалить его оттуда const oppositeType = type === 'avoid' ? 'favorite' : 'avoid'; const oppositeWrap = document.querySelector(`#personModal .tags-wrap[data-target="${oppositeType}"]`); const oppositeTags = oppositeWrap.querySelectorAll('.tag'); for(let t of oppositeTags) { if(t.textContent.trim().replace('×','').trim() === text) { t.remove(); break; } } const tag = document.createElement('div'); tag.className = `tag ${type === 'avoid' ? 'tag-red' : 'tag-green'}`; tag.innerHTML = cartPolicy.createHTML(`${text} ×`); tag.querySelector('.tag-close').addEventListener('click', function() { tag.remove(); }); tagsWrap.appendChild(tag); } // Автокомплит для полей в попапе document.querySelectorAll('#personModal .pref-input').forEach(input => { const type = input.dataset.type; const listEl = document.querySelector(`#personModal .autocomplete-list[data-type="${type}"]`); const tagsWrap = document.querySelector(`#personModal .tags-wrap[data-target="${type}"]`); input.addEventListener('input', function() { const val = this.value.toLowerCase(); listEl.innerHTML = cartPolicy.createHTML(''); // Ищем все уже выбранные теги в обеих группах внутри попапа const allSelectedTags = new Set(); document.querySelectorAll('#personModal .tags-wrap .tag').forEach(tagEl => { const tagText = tagEl.textContent.trim().replace('×', '').trim(); if(tagText) allSelectedTags.add(tagText); }); if(val.length > 0) { const matches = availableTags.filter(tag => tag.toLowerCase().includes(val) && !allSelectedTags.has(tag) ); if(matches.length > 0) { listEl.classList.add('active'); matches.forEach(match => { const div = document.createElement('div'); div.textContent = match; div.addEventListener('click', function() { addTagToModal(match, type); input.value = ''; listEl.classList.remove('active'); }); listEl.appendChild(div); }); } else { listEl.classList.remove('active'); } } else { listEl.classList.remove('active'); } }); document.addEventListener('click', function(e) { if(!input.contains(e.target) && !listEl.contains(e.target)) { listEl.classList.remove('active'); } }); }); // Сохранение пользователя (сбор тегов из DOM попапа) saveBtn.addEventListener('click', () => { const name = document.getElementById('inputName').value.trim(); const age = document.getElementById('inputAge').value.trim(); const weight = document.getElementById('inputWeight').value.trim(); const height = document.getElementById('inputHeight').value.trim(); const gender = document.querySelector('.gender-btn.active').dataset.gender; if(!name) { alert('Пожалуйста, введите имя.'); return; } if(!age || parseInt(age) < 7) { alert('Возраст должен быть от 7 лет.'); return; } // Сбор тегов const forbTags = []; document.querySelector('#personModal .tags-wrap[data-target="avoid"]').querySelectorAll('.tag').forEach(el => { forbTags.push(el.textContent.trim().replace('×', '').trim()); }); const favTags = []; document.querySelector('#personModal .tags-wrap[data-target="favorite"]').querySelectorAll('.tag').forEach(el => { favTags.push(el.textContent.trim().replace('×', '').trim()); }); const personData = { name, age, weight, height, gender, avoid: forbTags, favorite: favTags }; if (editingIndex !== null) { people[editingIndex] = personData; editingIndex = null; } else { people.push(personData); } renderPeople(); setOption('users', people); closeModal(); }); // --- 8. Блок: Категории --- const categories = initialData_g.available_groups; const initialSelected = initialData_g.initial_groups; let selectedCategories = [...initialSelected]; let isCategoriesExpanded = false; const maxVisibleCategories = 9; const sendUpdatedCategories = debounce(async () => { return setOption('categories', selectedCategories); }, 1500); const categoriesGrid = document.getElementById('categories-grid'); const toggleBtn = document.getElementById('expand-categories'); function renderCategories() { categoriesGrid.innerHTML = cartPolicy.createHTML(''); const dataToShow = isCategoriesExpanded ? categories : categories.slice(0, maxVisibleCategories); dataToShow.forEach(cat => { const btn = document.createElement('button'); btn.className = `category-btn ${selectedCategories.includes(cat) ? 'active' : 'inactive'}`; btn.textContent = cat; btn.dataset.category = cat; btn.addEventListener('click', function() { const catName = this.dataset.category; if (selectedCategories.includes(catName)) { selectedCategories = selectedCategories.filter(c => c !== catName); } else { selectedCategories.push(catName); } renderCategories(); sendUpdatedCategories(); }); categoriesGrid.appendChild(btn); }); if (isCategoriesExpanded) { toggleBtn.classList.remove('collapsed'); } else { toggleBtn.classList.add('collapsed'); } } toggleBtn.addEventListener('click', function() { isCategoriesExpanded = !isCategoriesExpanded; renderCategories(); }); renderCategories(); } document.addEventListener('DOMContentLoaded', async () => { wholeRender(await loadInitData()); }); document.addEventListener('DOMContentLoaded', function() { const popup = document.getElementById('cookie-popup'); const acceptBtn = document.getElementById('cookie-accept'); // Проверяем, согласился ли пользователь ранее if (!localStorage.getItem('cookie_accepted')) { // Небольшая задержка для красоты появления setTimeout(() => { popup.classList.add('show'); }, 500); } // Обработчик нажатия на кнопку "Хорошо" acceptBtn.addEventListener('click', function() { // Сохраняем согласие в браузер localStorage.setItem('cookie_accepted', 'true'); // Запускаем анимацию исчезновения popup.classList.remove('show'); popup.classList.add('hide'); // Полностью удаляем из DOM после завершения анимации setTimeout(() => { popup.style.display = 'none'; }, 400); }); });