feat: add favorites feature for users
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s

- Implemented `Favorite` model with unique user-item constraint and timestamped records
- Added `favorites:index` and `favorites:toggle` views with AJAX support for toggling favorites
- Built templates and front-end assets for the favorites page and integration with item grids
- Extended header with favorites link and dynamic counter
- Added tests covering models, views, and templates for the favorites functionality
- Updated admin to enable management of favorites via Django admin interface
This commit is contained in:
2026-05-10 20:06:12 +02:00
parent 812065a34b
commit 29d8302a4f
30 changed files with 1042 additions and 65 deletions

114
static/js/favorites.js Normal file
View File

@@ -0,0 +1,114 @@
document.addEventListener('DOMContentLoaded', function () {
const favoriteForms = document.querySelectorAll('[data-favorite-form]');
const pluralizeFavorites = function (count) {
const value = Math.abs(Number(count) || 0);
const lastTwo = value % 100;
if (lastTwo >= 11 && lastTwo <= 14) {
return 'товаров';
}
switch (value % 10) {
case 1:
return 'товар';
case 2:
case 3:
case 4:
return 'товара';
default:
return 'товаров';
}
};
const updateFavoritesCounter = function (favoriteCount) {
const valueNode = document.querySelector('[data-favorites-count-value]');
const labelNode = document.querySelector('[data-favorites-count-label]');
if (valueNode) {
valueNode.textContent = String(favoriteCount);
}
if (labelNode) {
labelNode.textContent = pluralizeFavorites(favoriteCount);
}
};
const ensureEmptyFavoritesState = function () {
const grid = document.querySelector('[data-favorites-grid]');
if (!grid) {
return;
}
const cards = grid.querySelectorAll('[data-item-card]');
if (cards.length > 0) {
return;
}
const emptyMessage = grid.dataset.emptyMessage || 'В избранном пока нет товаров.';
grid.innerHTML = '<p class="item-list-empty">' + emptyMessage + '</p>';
};
const syncButtonState = function (button, payload) {
const isFavorite = Boolean(payload.is_favorite);
const addLabel = button.dataset.labelAdd || 'Добавить в избранное';
const removeLabel = button.dataset.labelRemove || 'Удалить из избранного';
const label = isFavorite ? removeLabel : addLabel;
button.classList.toggle('is-active', isFavorite);
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
};
favoriteForms.forEach(function (form) {
form.addEventListener('submit', async function (event) {
event.preventDefault();
const button = form.querySelector('[data-favorite-button]');
if (!button || form.dataset.loading === '1') {
return;
}
form.dataset.loading = '1';
button.disabled = true;
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error('Failed to update favorites');
}
const payload = await response.json();
const card = form.closest('[data-item-card]');
const favoritesPage = Boolean(document.querySelector('[data-favorites-page]'));
syncButtonState(button, payload);
updateFavoritesCounter(payload.favorite_count);
if (card) {
card.classList.toggle('is-favorite', Boolean(payload.is_favorite));
}
if (favoritesPage && !payload.is_favorite && card) {
card.remove();
ensureEmptyFavoritesState();
}
} catch (error) {
console.error(error);
HTMLFormElement.prototype.submit.call(form);
return;
} finally {
form.dataset.loading = '0';
button.disabled = false;
}
});
});
});

View File

@@ -0,0 +1,56 @@
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-category-dropdown]').forEach(function (dropdown) {
const trigger = dropdown.querySelector('[data-category-trigger]');
const label = dropdown.querySelector('[data-selected-label]');
const valueInput = dropdown.querySelector('[data-category-value]');
const menu = dropdown.querySelector('[data-category-menu]');
const options = dropdown.querySelectorAll('[data-category-option]');
if (!trigger || !label || !valueInput || !menu) {
return;
}
const setExpanded = function (expanded) {
trigger.setAttribute('aria-expanded', expanded ? 'true' : 'false');
dropdown.classList.toggle('is-open', expanded);
};
const closeDropdown = function () {
setExpanded(false);
};
const openDropdown = function () {
setExpanded(true);
};
trigger.addEventListener('click', function (event) {
event.preventDefault();
if (dropdown.classList.contains('is-open')) {
closeDropdown();
return;
}
openDropdown();
});
options.forEach(function (option) {
option.addEventListener('click', function () {
label.textContent = option.dataset.label || option.textContent.trim();
valueInput.value = option.dataset.value || '';
closeDropdown();
});
});
document.addEventListener('click', function (event) {
if (!dropdown.contains(event.target)) {
closeDropdown();
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
closeDropdown();
}
});
});
});