feat: add favorites feature for users
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
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:
114
static/js/favorites.js
Normal file
114
static/js/favorites.js
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
56
static/js/header-category-dropdown.js
Normal file
56
static/js/header-category-dropdown.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user