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

View File

@@ -7,19 +7,19 @@
<div class="footer-col">
<h4>ПОКУПАТЕЛЮ</h4>
<ul>
<li><a href="#">Каталог</a></li>
<li><a href="#">Услуги</a></li>
<li><a href="#">Акции</a></li>
<li><a href="#">Новинки</a></li>
<li><a href="/catalog/">Каталог</a></li>
<li><a href="{% url 'services' %}">Услуги</a></li>
<li><a href="/catalog/">Акции</a></li>
<li><a href="/catalog/">Новинки</a></li>
</ul>
</div>
<div class="footer-col">
<h4>ПОМОЩЬ</h4>
<ul>
<li><a href="#">Доставка и оплата</a></li>
<li><a href="#">Гарантия и возврат</a></li>
<li>Ответы на вопросы</li>
<li><a href="#">Контакты</a></li>
<li><a href="{% url 'shipping' %}">Доставка и оплата</a></li>
<li><a href="{% url 'warranty' %}">Гарантия и возврат</a></li>
<li><a href="{% url 'home' %}#faq-section">Ответы на вопросы</a></li>
<li><a href="{% url 'contacts' %}">Контакты</a></li>
</ul>
</div>
<div class="footer-col">

View File

@@ -5,13 +5,67 @@
<div class="logo"><a href="/">SantehRay</a></div>
<form action="">
<div class="search-wrap">
<label>
<select>
<option>Все категории</option>
<option>Сантехника</option>
<option>Декор</option>
</select>
</label>
<div class="search-select-wrap">
<div class="search-category-dropdown" data-category-dropdown>
<button
type="button"
class="search-category-trigger"
aria-label="Выбор категории"
aria-expanded="false"
aria-haspopup="listbox"
data-category-trigger
>
<span class="search-category-label"
data-selected-label>{{ navigation_selected_label }}</span>
<svg class="search-select-arrow" xmlns="http://www.w3.org/2000/svg" width="14"
height="14"
viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M3.2 6.2 8 11l4.8-4.8" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<input type="hidden" name="category" value="{{ navigation_selected_value }}"
data-category-value>
<div class="search-category-menu" role="listbox" aria-label="Категории и подкатегории"
data-category-menu>
<button
type="button"
class="search-category-option search-category-option--all"
data-category-option
data-label="Все категории"
data-value=""
>
Все категории
</button>
{% for category in navigation_categories %}
<div class="search-category-group">
<div class="search-category-sublist">
<button
type="button"
class="search-category-option search-category-option--category"
data-category-option
data-label="{{ category.name }}"
data-value="category:{{ category.slug }}"
>
{{ category.name }}
</button>
{% for subcategory in category.subcategories.all %}
<button
type="button"
class="search-category-option search-category-option--subcategory"
data-category-option
data-label="{{ category.name }} / {{ subcategory.name }}"
data-value="subcategory:{{ category.slug }}:{{ subcategory.slug }}"
>
{{ subcategory.name }}
</button>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<label>
<input class="search-input" type="text" placeholder="Кто ищет, тот найдет...">
</label>
@@ -24,7 +78,8 @@
</div>
</form>
<div class="header-icons">
<a href="">
<a href="{% if request.user.is_authenticated %}{% url 'favorites:index' %}{% else %}{% url 'users:login' %}{% endif %}"
aria-label="Избранное">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-heart" viewBox="0 0 16 16">
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01zM8 15C-7.333 4.868 3.279-3.04 7.824 1.143q.09.083.176.171a3 3 0 0 1 .176-.17C12.72-3.042 23.333 4.867 8 15"></path>

View File

@@ -0,0 +1,7 @@
<div class="items-grid"{% if favorites_grid %} data-favorites-grid data-empty-message="{{ empty_message|escape }}"{% endif %}>
{% for item in items %}
{% include "catalog/_fragments/item-card.html" with item=item %}
{% empty %}
<p class="item-list-empty">{{ empty_message }}</p>
{% endfor %}
</div>

View File

@@ -16,6 +16,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1/dist/fancybox/fancybox.css"/>
<script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1/dist/fancybox/fancybox.umd.js"></script>
<script src="{% static 'js/header-category-dropdown.js' %}"></script>
{% block add_css %}
<!-- Additional CSS -->
{% endblock %}
@@ -48,9 +49,10 @@
closeButton.addEventListener('click', hideToast)
}
window.setTimeout(hideToast, 4000)
window.setTimeout(hideToast, 4000)
})
})
</script>
<script src="{% static 'js/favorites.js' %}"></script>
</body>
</html>

View File

@@ -0,0 +1,57 @@
<article class="item-card{% if item.id in favorite_item_ids %} is-favorite{% endif %}" data-item-card="{{ item.id }}">
<a class="item-card-media-link" href="{% url 'detail' item.category.slug item.slug %}" aria-label="Открыть товар {{ item.name }}">
{% with primary_image=item.primary_image %}
<div class="item-card-image">
{% if primary_image %}
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
{% else %}
<div class="item-card-placeholder">Нет фото</div>
{% endif %}
</div>
{% endwith %}
</a>
<div class="item-card-body">
<div class="item-card-header">
<div class="item-card-category">
{{ item.category.name }}{% if item.subcategory %} / {{ item.subcategory.name }}{% endif %}
</div>
{% if request.user.is_authenticated %}
<form class="item-card-favorite-form" method="post" action="{% url 'favorites:toggle' %}" data-favorite-form>
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button
class="item-card-favorite-button{% if item.id in favorite_item_ids %} is-active{% endif %}"
type="submit"
title="{% if item.id in favorite_item_ids %}Удалить из избранного{% else %}Добавить в избранное{% endif %}"
aria-label="{% if item.id in favorite_item_ids %}Удалить из избранного{% else %}Добавить в избранное{% endif %}"
data-favorite-button
data-label-add="Добавить в избранное"
data-label-remove="Удалить из избранного"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false">
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01z"></path>
</svg>
</button>
</form>
{% else %}
<a class="item-card-favorite-button item-card-favorite-button--login" href="{% url 'users:login' %}?next={{ request.get_full_path|urlencode }}" title="Войдите, чтобы добавить в избранное" aria-label="Войдите, чтобы добавить в избранное" data-favorite-button data-label-add="Добавить в избранное" data-label-remove="Удалить из избранного">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false">
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01z"></path>
</svg>
</a>
{% endif %}
</div>
<a class="item-card-title-link" href="{% url 'detail' item.category.slug item.slug %}" aria-label="Открыть товар {{ item.name }}">
<h2 class="item-card-title">{{ item.name }}</h2>
</a>
<div class="item-card-footer">
<span class="item-card-price">{{ item.price }} руб.</span>
<span class="item-card-availability {% if item.is_available %}available{% else %}unavailable{% endif %}">
{% if item.is_available %}В наличии{% else %}Нет в наличии{% endif %}
</span>
</div>
</div>
</article>

View File

@@ -1,34 +1,3 @@
<main class="item-list">
<div class="items-grid">
{% for item in items %}
<article class="item-card">
<a class="item-card-link" href="{% url 'detail' item.category.slug item.slug %}" aria-label="Открыть товар {{ item.name }}">
{% with primary_image=item.primary_image %}
<div class="item-card-image">
{% if primary_image %}
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
{% else %}
<div class="item-card-placeholder">Нет фото</div>
{% endif %}
</div>
{% endwith %}
<div class="item-card-body">
<div class="item-card-category">
{{ item.category.name }}{% if item.subcategory %} / {{ item.subcategory.name }}{% endif %}
</div>
<h2 class="item-card-title">{{ item.name }}</h2>
<div class="item-card-footer">
<span class="item-card-price">{{ item.price }} руб.</span>
<span class="item-card-availability {% if item.is_available %}available{% else %}unavailable{% endif %}">
{% if item.is_available %}В наличии{% else %}Нет в наличии{% endif %}
</span>
</div>
</div>
</a>
</article>
{% empty %}
<p class="item-list-empty">В этой категории пока нет товаров.</p>
{% endfor %}
</div>
{% include "_fragments/item-grid.html" with items=items empty_message="В этой категории пока нет товаров." %}
</main>

View File

@@ -0,0 +1,39 @@
{% extends "_layouts/base.html" %}
{% load static %}
{% block title %}Избранное{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static "css/catalog.css" %}">
<link rel="stylesheet" href="{% static "css/favorites.css" %}">
{% endblock %}
{% block content %}
{% include "_fragments/header.html" with active="favorites" %}
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/favorites/' text_2='Избранное' %}
<main class="container favorites-page" data-favorites-page>
<section class="favorites-hero">
<p class="favorites-kicker">Личный список</p>
<div class="favorites-hero__row">
<div>
<h1 class="favorites-title">Избранные товары</h1>
<p class="favorites-description">
Здесь собраны товары, которые вы хотите сохранить для быстрого возврата к ним позже.
</p>
</div>
<div class="favorites-counter">
<span class="favorites-counter__value" data-favorites-count-value>{{ favorite_count }}</span>
<span class="favorites-counter__label" data-favorites-count-label>товаров</span>
</div>
</div>
</section>
<section class="favorites-list" aria-label="Список избранных товаров">
{% include "_fragments/item-grid.html" with items=items empty_message="В избранном пока нет товаров." favorites_grid=True %}
</section>
</main>
{% include "_fragments/footer.html" %}
{% endblock %}