703 lines
28 KiB
JavaScript
703 lines
28 KiB
JavaScript
|
|
const host = 'https://hvatilo.ru';
|
|||
|
|
// 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 = `
|
|||
|
|
<div class="item-image"><img src="${item.image}" alt=""></div>
|
|||
|
|
<div class="item-details">
|
|||
|
|
<div class="item-name">${item.name}</div>
|
|||
|
|
<div class="item-price">${amount} × ${Math.round(item.price * 100) / 100} ₽</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="menu-btn-wrapper">
|
|||
|
|
<button class="menu-btn" data-index="${index}">⋮</button>
|
|||
|
|
<div class="menu-dropdown">
|
|||
|
|
<ul>
|
|||
|
|
<li class="menu-replace">Заменить</li>
|
|||
|
|
<li class="menu-remove">Удалить</li>
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
return li;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
let cartList;
|
|||
|
|
let scrollWrapper;
|
|||
|
|
let showMoreBtn;
|
|||
|
|
let remainingSpan;
|
|||
|
|
let initialData_g;
|
|||
|
|
|
|||
|
|
function renderProducts() {
|
|||
|
|
cartList.innerHTML = '';
|
|||
|
|
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 = `${allProducts.length} ${declension(allProducts.length, ['товар', 'товара', 'товаров'])} • ${totalPrice} ~ с запасами на ${x} ${declension(x, ['день', 'дня', 'дней'])}, точность ${Math.round(initialData_g.precision * 100)/100}%`;
|
|||
|
|
document.getElementById('total-price').innerHTML = `${totalPrice}`;
|
|||
|
|
if(allProducts.length === 0) {
|
|||
|
|
document.getElementById('summary-details').innerHTML = `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 = '';
|
|||
|
|
people.forEach((person, index) => {
|
|||
|
|
const avatar = '🥦';
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.className = 'family-member';
|
|||
|
|
div.dataset.index = index;
|
|||
|
|
div.innerHTML = `
|
|||
|
|
<div class="avatar">${avatar}</div>
|
|||
|
|
<div class="info">
|
|||
|
|
<div class="name">${person.name}</div>
|
|||
|
|
<div class="age">${person.age} лет</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="delete-member" data-index="${index}">×</div>
|
|||
|
|
`;
|
|||
|
|
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 = '');
|
|||
|
|
|
|||
|
|
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 = `${text} <span class="tag-close">×</span>`;
|
|||
|
|
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 = '';
|
|||
|
|
|
|||
|
|
// Ищем все уже выбранные теги в обеих группах внутри попапа
|
|||
|
|
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 = '';
|
|||
|
|
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);
|
|||
|
|
});
|
|||
|
|
});
|